Compare commits

...
Author SHA1 Message Date
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 d01cdc040f fix🐛: accept the import table list from a JSON body as well
The generator's import reads its comma-separated table list with
c.Request.FormValue("tables"), which on a request declaring itself as JSON reads
the URL query and nothing else. go-admin-ui v3.2.0 began sending that list in
the body, so the handler saw an empty string, asked information_schema for a
table named "", and every import failed with "table name cannot be empty!" —
on a fresh installation that is the first thing the generator is asked to do.

tablesToImport reads the query first and falls back to the body, so a front end
sending either works against this server. It also drops blank entries:
splitting "" yields one empty name rather than nothing at all, which is why the
old code reached a database query at all before failing.

The front end sends the list in the query again on its side; this half is what
lets an installation already running v3.2.0 recover without changing it.
2026-09-16 17:57:51 +08:00
zhangwenjian 92b9af17b7 refactor🎨: name the empty-table-name message once
The string was spelled out at each site that raises it, and once more in the
test file that asserts on it. A test holding its own copy cannot tell the
difference between the handler answering something else and the message having
been reworded: it goes on asserting a string the server no longer sends, and
goes on passing.

The three copies in app/other/models/tools are left alone; they are raised from
a different layer and nothing asserts on them.
2026-09-16 17:57:37 +08:00
zhangwenjian 898e1b023a refactor🎨: share the generator tests' engine and response decoding
newEngine takes the method, path and handler, so a second test file does not
have to restate the sqlite connection, the driver override and its cleanup, the
CustomError middleware and the two context keys. serveJSON does the same for
running one request and decoding the envelope.

Nothing about what is asserted changes; newColumnListEngine and columnListMsg
keep their names and their callers.
2026-09-16 17:57:28 +08:00
wenjianzhang a90c67473e Merge pull request #924 from jackwalkerlabs/fix/job-stop-timeout-890
fix🐛: report job stop timeouts as errors
2026-09-16 08:02:47 +08:00
wenjianzhang 1a84b8a892 Merge pull request #930 from Tuoxie423/docs/remove-dead-contributor-links
docs📝: 删除 README 中失效的贡献者链接
2026-09-16 08:02:43 +08:00
wenjianzhang 656d14cd54 Merge pull request #934 from go-admin-team/fix/928-929-seed-repair
Reseeding a menu tree repairs what it finds, and finds what it wrote
2026-09-14 14:52:33 +08:00
zhangwenjian 9dd271ecab fix🐛: claim the rows an application wrote before seed_code existed
1786700008000 added sys_menu.seed_code and left it NULL on every row that was
already there. That is right for the host's own hand-placed menus: there is
nothing to derive one from.

An application's rows are in that population too, and for those it is
derivable - menu_name is what identified them before the column existed. The
natural-key lookup missed them, so a reseed inserted a second copy beside each
one, and the new unique index could not object, because NULL never collides on
MySQL, PostgreSQL or SQLite and is filtered out of the index on SQL Server.

Claimed when the application is seeded rather than by a backfill migration.
The value is only derivable where the spec's own Code is in hand: menuName
concatenates two pascalCase strings and does not reverse, so a migration
looking at menu_name alone would be guessing. For the same reason more than
one match is refused and named rather than picked from - attaching an
application's menu to whichever row the database returned first is the failure
this is meant to prevent, not a smaller version of it.

The match is scoped to the application's own app_code, so a row belonging to
another application, or to the host, is not claimed.

An adopted row then goes through the ordinary repair, so it comes out carrying
what the spec says rather than what it held from before.

Three degradations turn the new assertions red: not adopting at all, picking a
row when there is more than one, and dropping the app_code from the match.
2026-09-13 20:34:04 +08:00
zhangwenjian 4973ee030d fix🐛: bring an existing seeded menu up to what the spec says
repairExistingMenu reconciled a row's paths and its api bindings and left
every other column as an earlier run had written it. That cost three different
things, and none of them announced itself.

A menu whose parent was removed and seeded again kept parent_id pointing at
the dead row while its paths named the new one. The tree is built from
parent_id - SysMenu.GetPage walks down from ParentId == 0 - so the menu was
gone from the sidebar, with the migration reporting success.

A menu somebody added by hand under a seeded one kept the old prefix when its
ancestor moved. It is in no spec, so nothing else would ever rewrite it;
SysMenu.Update already does this cascade for the same column when a menu is
moved through the UI.

An application that renamed a menu, or moved its component, in a new version
had the change ignored: the row was found by its natural key and returned
untouched.

The row a spec describes now has one definition, and both the insert and the
repair use it - they cannot drift into disagreeing about what a spec decides.
The repair writes every column on that list.

Visible and IsFrame are deliberately not on it. They are seeding defaults the
application never expressed, so an administrator who hid a seeded menu keeps
it hidden; there is a test that hides one and reseeds.

The cascade matches the row itself or a row strictly underneath it, rather
than `paths LIKE old || '%'`, which also catches /0/1/20 when old is /0/1/2.
An empty old path takes the single-row branch instead: there is no subtree
under one, and the LIKE would have matched the whole table.

Five degradations turn the new assertions red: not writing the spec columns at
all, leaving ParentId off the list, putting Visible on it, not cascading, and
cascading on the loose prefix. The last one did not, at first - the decoy rows
were built against the path of the menu whose parent moved rather than the
path that actually gets rewritten, so the prefix they collided with was never
the one passed to the query.
2026-09-13 20:33:47 +08:00
wenjianzhang 137bb3ad33 Merge pull request #931 from go-admin-team/feat/008-wire-example
008 layer three: status reads sys_app, dependencies are checked, and the example gets installed for real
2026-09-12 21:17:33 +08:00
zhangwenjian 03d587db6a ci👷: give the end-to-end install a make target
It was an inlined `go test` in the workflow, and the only thing in the build
that runs it. Every other gate there goes through make - make test, make
build, make checksilent - and `make test` is `go test ./...` in this module,
which cannot reach test/e2e-apporder because that is a module of its own.

So the one check that exercises installing an application was the one check a
developer had no command for, and the only place it could turn red was after
pushing.
2026-09-11 08:23:42 +08:00
zhangwenjian 1f56b956d2 test✅: build the end-to-end binary once, and assert from one list
Three tests each called newEnv, and newEnv built the binary, so the same
binary was linked three times - about 17 seconds of the run, measured. A
binary is read-only and there is nothing to isolate between tests; each test
still gets its own directory and its own database. The package now builds it
on the first test that needs one and removes it in TestMain. The suite goes
from 39 seconds to 9.

The three assertion blocks listed the same six queries, two or three times
each, differing only in the counts expected - so renaming a table meant
finding three places. They now share one list, with a flag for the uninstall's
"all of them at zero". The reinstall check gets stronger on the way past: it
was three of the six and is now all six.

The per-call sql.Open in count and exec stays. It looks like waste and is not:
the binary under test writes the same file, and a connection held open across
a run of it is a second writer for nothing. The shared part is factored out;
the opening is still per call, and the comment now says why.
2026-09-11 08:21:36 +08:00
zhangwenjian 37aece9791 test✅: share one sys_app fixture, and count through the helper that checks
Two tests in this package each wrote out what an installed sys_app row looks
like, field for field, and a third inlined the same Create with a different
status. One appRow(t, db, code, status) now covers all three, so a new NOT
NULL column on SysApp is one edit rather than three.

One assertion counted with a bare db.Model(...).Count(&n) and dropped the
error that call returns. A failing query leaves n at zero, which is exactly
what that assertion wanted to see - so the test would have passed on a broken
query. The package already had a count helper that fails on the error, and
this now uses it.

Also a cycle reached from outside itself. The existing case walks straight
into its own cycle from the first code, so the path trimming had nothing to do
and replacing it with the untrimmed path left the test green - the trimming
was never covered. With a requiring b, b requiring c and c requiring b, the
untrimmed report names a as part of a cycle it is not in, and the test goes
red.
2026-09-11 08:21:35 +08:00
zhangwenjian 309b400bc0 refactor♻️: take one registry snapshot, and one route into sys_app
Cleanup from a review pass over this branch. No behaviour changes except the
two noted below.

runInstall took app.Snapshot() twice, once inside manifestFor and once for the
cycle check. Snapshot is a deep copy of the registry, and worse than the
copying, the two calls could in principle disagree - the set the cycle check
validated was not provably the set the manifest came from. One snapshot,
passed to both.

appSummary converted a display code back to a stored one with
NormalizeAppCode, which is not that inverse: it leaves "core" as "core", so
the framework needed a branch of its own to stay out of the listing. AppFilter
is the documented inverse and maps it to the empty string, which is not a code
any row is filed under - so the branch goes, and the function now matches
filterAppsByApp twenty lines below it, which was already using AppFilter.

That branch only half-covered what it guarded: a sys_app row carrying an empty
or reserved app_code was still merged into the framework's group by
groupByApp, with only its summary suppressed. loadApps now drops such rows,
which is the one place that settles it for every reader of the map.

requiresInstalled built two parallel slices with a tuple assignment repeated in
three branches; it now picks a reason and appends once. Its last arm was a
catch-all on "not installed", so a status constant added later would have been
described as "did not finish" - a sentence that would be wrong for whatever
reason the constant was added. Unrecognised values now say so. It also takes
the normalised code the caller already has rather than computing it a third
time.

refuseOnDependencyCycle sorted each manifest's Requires before walking them.
Requires is a slice and already has a fixed order, so the sort bought no
determinism - that comes from the sorted outer loop, which walks a map - and
only made a reported cycle harder to line up against the manifest that caused
it. The filtering pass that went with it is covered by the registration check
underneath. The cycle path is trimmed with slices.Index, which also removes a
fallback return that the grey/path invariant made unreachable.
2026-09-11 08:20:56 +08:00
zhangwenjian 1b9868b72b feat✨: refuse an install whose dependencies are not installed
An application's manifest can name others it needs. Until now the list was
stored and never read.

It is checked, not satisfied. Installing the dependencies too would make
"install this application" mean "and everything it happens to name, and
everything those name" - a blast radius the operator did not ask for and
cannot see beforehand. What they get is the list and the order to do it in.

A dependency whose own install failed, or never finished, is not a dependency
that is there. The message says which, because the two send you to different
places: one to install it, the other to look at why it did not take.

The check runs before anything is written, so a refusal cannot cost the
operator the row that told them what they had.

Separately, a cycle anywhere in the registered manifests is refused, whether
or not the application being installed is in it. A cycle between two others is
still an authoring mistake, and the day somebody installs into it - with an
error naming two applications they did not ask for - is the worse time to find
out. The error is the cycle rather than the walk that reached it, and the
walk's order is sorted, so the same set of manifests always reports the same
one. Requires naming an application that is not registered is not a cycle; it
is the database's answer to give, at the time it matters.

Six degradations turn the new assertions red: accepting any dependency,
accepting a row regardless of its status, returning no cycle, not trimming the
reported path to the cycle itself, and running the check after the row has
already been written - the last of which was rebuilt after the first attempt
at it deleted the check rather than moving it, and so went red on the wrong
assertion.
2026-09-10 21:30:29 +08:00
zhangwenjian 25344aa572 feat✨: let migrate status say what sys_app knows
The migration rows answer "did this run". They cannot answer "is this
application installed", and the difference is not academic: an install that
stopped partway leaves every migration reading applied and a row saying the
install never finished. Until now nothing printed that row.

    [order]  1.0.0 failed at order-1793800000000
      applied   order-1793800000000  2026-09-10 21:02:07

The application list is the union of the two sources rather than either one.
Reading it from sys_app alone would drop an application whose migrations ran
under plain `migrate`, which records no row; reading it from the migration
rows alone drops one whose code has been taken out of the binary, which is
when somebody most wants to see it named - that one now gets a group of its
own, empty, saying why.

A database from before sys_app existed prints exactly what it printed before.
`migrate status` has to keep working on a database that has not been migrated
at all, which is when it is most wanted.

Four degradations turn the new assertions red: dropping the sys_app-only
applications from the listing, printing no summary, not narrowing sys_app by
--app, and reporting an unfinished install as an installed one.
2026-09-10 21:30:03 +08:00
拖鞋423 ea9d27cf6d docs📝: 删除 README 中失效的贡献者链接 2026-09-09 20:28:35 +08:00
zhangwenjian 0bee8ec46c ci👷: run the end-to-end install on every push
The separate module is invisible to `go test ./...` in the main module, which
is the point, and also means nothing would ever run it. This step does.
2026-09-09 16:40:15 +08:00
zhangwenjian 3a5afeb518 test✅: install and uninstall the example application end to end
Everything under `migrate install` was covered with an injected engine and a
hand-built schema, which is where the shapes belong. What none of it could
catch is the wiring: whether an application's init() reaches both registries,
whether the installer finds a manifest through app.Snapshot, whether the
seeder writes what the uninstaller goes looking for, and whether the command
exits non-zero when a migration fails - which a deployment reads to decide
whether to start the new version.

This builds a go-admin binary with the example application linked in, migrates
a real database with it, and drives the whole sequence: framework migrations
only, install, install again, put a row in the application's own table,
uninstall, reinstall.

It lives in its own module. A tagged import in the main module would still be
resolved by `go mod tidy`, which considers every build tag and would go
looking for github.com/go-admin-team/example-app-order on the network - a
repository that does not exist, because the example is a directory inside this
one. That was checked rather than assumed: tidy fails there with "Repository
not found". A build tag of `ignore` is skipped by tidy but cannot be turned on
either, because the standard library uses it for files that are not meant to
build at all. A separate module with replace directives is invisible to the
main module's tidy, its build, its tests and checksilent, and needs no
go.work.

Three degradations turn it red: the example application not registering a
manifest, the uninstall not clearing sys_migration - where the reinstall then
seeds nothing and the assertion reads "menus = 0, want 4" - and the seeder not
recording its grants, where the uninstall then leaves every policy behind.
2026-09-09 16:40:15 +08:00
zhangwenjian fc8ba4d615 feat✨: let the example application say what it is
app-order registered its migrations and its menus and nothing else, so
`migrate install order` answered that no application in the binary registers a
manifest. Which was true, and made the installer untestable against the one
application this repository ships.

The manifest goes in the migration package rather than one of its own because
that is the package a host has to import for the application to exist at all -
its migrations register from there too. A second package would be a second
thing to remember to import, and forgetting it would leave an application
whose migrations run and which no installer can name.

Its Version is not the migration version and the two move independently:
adding a migration file without renaming the application is normal, and so is
a release that changes no schema. The migration versions decide what runs;
this decides what the installed row says.

go-admin-core moves to v2.8.0, which is where contract/app lives.
2026-09-09 16:40:14 +08:00
wenjianzhang dc20062e5b Merge pull request #927 from go-admin-team/fix/sqlserver-null-unique
The seed natural-key indexes cannot be built on SQL Server
2026-09-09 16:19:26 +08:00
zhangwenjian ff430c509b ci👷: run the migration tests against SQL Server too
The fourth registered driver, and the one that disagrees with the other three
about NULL. A suite that never pointed at it reported success for a migration
no SQL Server database could apply - the same shape as the PostgreSQL gap that
put the postgres service here, one driver further along.

SQL Server has no equivalent of POSTGRES_DB, so the database the DSN names is
created in a step before the tests. The test helper fails rather than skips
when CI is set and the variable is not, so dropping the service or renaming
the variable cannot quietly go green.
2026-09-09 13:49:20 +08:00
zhangwenjian d43d7a46dd fix🐛: make the seed natural-key indexes buildable on SQL Server
1786700008000 could not be applied to any SQL Server database. Not an old one
with awkward data - any of them, including an empty one:

    Msg 1505 ... duplicate key ... The duplicate key value is (, <NULL>, 0).

MySQL, PostgreSQL and SQLite treat two NULLs in a unique index as different
values, so any number of rows missing a seed_code coexist under
uk_sys_menu_app_seed_code_del. SQL Server treats them as equal and permits
exactly one. 1786700001000 seeds five menus and none of them has a seed_code,
so the second one already collides with the first. sys_api's index has the
same shape over two nullable columns, path and action.

On SQL Server the index is now filtered to the rows that carry a value, which
is what the other three engines do by not comparing their NULLs. The filter is
not added elsewhere: MySQL has no filtered index at all, and on PostgreSQL and
SQLite it would only restate what those engines already do.

Nothing that has applied this migration is affected, and no SQL Server
database can have.

Verified against SQL Server 2022. The migration completes; the filtered index
still rejects a second (order, dir) and still lets another app reuse "dir",
so filtering removed the NULL rows from the index rather than the index's
teeth. Two degradations turn that red: dropping the filter, and naming only
path in sys_api's - the second one needed a fixture row with a path and no
action, because rows missing both are excluded either way and the first
attempt at that degradation came out green.

There is also a control test asserting the unfiltered statement still fails on
this engine, so the first test is passing because of the fix rather than
because SQL Server turned out not to mind.
2026-09-09 13:49:20 +08:00
wenjianzhang 72c496ab93 Merge pull request #926 from go-admin-team/feat/008-installer
008 layer two: the installer and the uninstaller
2026-09-09 13:43:08 +08:00
zhangwenjian b228152308 docs📝: drop a reference to a document this repository does not carry
Two comments added in this branch cite docs-prd/008-.../数据库变更.md by path.
That directory is not tracked here, so the citation reads as a file the reader
can open and cannot. The reasoning it pointed at is short enough to state in
place.

Three comments from the previous batch cite the same path and are left alone;
they belong to a different change.
2026-09-09 13:28:45 +08:00
zhangwenjian 006756ea40 test✅: cover the uninstall's chunk boundary and its empty id lists
Copilot could not review this branch - the account is over its review quota -
so these are what a second pass over the uninstaller turned up. No defect: the
three cases were uncovered rather than wrong.

findOrphanPolicies batches its OR chain because a driver runs out of
placeholders long before an application runs out of endpoints, and nothing
exercised the boundary. 205 paths across three batches, the last one short,
plus one policy no key names as a control. Taking one fewer per batch,
advancing one too far, and stopping after the first batch each turn it red.

An application with apis and no menus, and one with menus and no apis, are
both normal - endpoints another service calls, or a section with no endpoints
of its own - and each leaves one of the two id lists the uninstall reads
empty.

That last pair also corrected a comment. The guard in front of the join-table
delete was described as being there because an empty IN list is a syntax
error. It is in raw SQL, but GORM renders IN with an empty slice as a
condition that matches nothing, and removing the guard leaves the new test
green. It stays as a statement of intent, and now says so.
2026-09-09 12:56:33 +08:00
zhangwenjian aa539c061f feat✨: uninstall one application's menus, apis and grants
`migrate uninstall <code>` removes what an application's install wrote and
leaves the application's own tables alone. Removing an order module is not the
same decision as destroying the orders, and nothing here can tell an operator
who is done with it from one who will reinstall tomorrow.

One transaction, and this one really is one: every statement is DML or a
SELECT, so unlike an install there is no DDL to commit it out from under
itself. Child rows go first, while the ids that identify them can still be
read from their parents, and the api paths are read before the rows carrying
them are deleted.

The two join tables need no ledger and get none. menu_id is a surrogate key,
so a sys_role_menu or sys_menu_api_rule row can only have come from a menu
this application wrote - there is no "looks like it but is not". A column on
sys_role_menu would have been worse than unnecessary: SysRole.Update deletes a
role's rows and writes them back through GORM's many2many, which does not
carry extra columns, so the column would be blanked the first time anybody
edited a role, silently. There is a test that performs that edit and then
uninstalls.

casbin_rule is the opposite case, because its key is business text somebody
else may have written for their own reasons. Policies are removed one at a
time, by exact tuple, and only the ones the ledger says this install created.
A tuple the ledger names that is no longer there is reported, not treated as a
failure - the uninstall wanted it gone and it is gone. Then, with everything
the ledger could speak for already dealt with, a read-only pass lists the
policies still naming this application's paths: those are grants somebody made
by hand, they are about to point at APIs that no longer exist, and they are
not this command's to delete. The two lists stay separate because they mean
different things - one is something of ours that had already gone, the other
is somebody else's grant now pointing at nothing - and merged into one "could
not remove" list neither would be actionable.

sys_migration's rows for the application go too. Without that a reinstall
finds every version applied, runs no migration, seeds nothing, and reports
success. It is the easiest step to leave out, because a migration record does
not look like the application's data.

A sys_app row is not required. `migrate` with no subcommand applies every
registered migration, an application's included, so an application can have
all of its rows without ever having gone through the installer - and that is
the case where nothing else can clean up after it.

Eleven degradations were applied one at a time, each red on the assertion it
was aimed at: skipping either join table, deleting sys_role_menu without its
filter, skipping sys_migration, deleting sys_migration without its filter,
matching policies by path instead of by ledger tuple, dropping the orphan
pass, treating a missing policy as a failure, leaving the ledger behind, soft
deleting sys_menu instead of removing it, and running the whole thing outside
a transaction.
2026-09-09 12:35:35 +08:00
zhangwenjian 74b0ee8776 fix🐛: stop sys_menu declaring an index stricter than the real one
uk_sys_menu_app_seed_code_del covers (app_code, seed_code, deleted_at) and is
created by 1786700008000 with explicit SQL. The struct tag named the same
index on SeedCode alone, and a named uniqueIndex tag collects only the fields
carrying that name - so AutoMigrate on this model would build a unique index
on seed_code by itself: stricter than the real one, and forbidding two
applications from both having a "dir" node, which the composite key exists to
allow.

Worse than being stricter, it would win. The migration only creates its index
when HasIndex says the name is free, so a schema built by AutoMigrate first
keeps the wrong index and the migration steps over it without a word.

The tag cannot express the real index: deleted_at comes from the ModelTime
embed shared by every table, which no single model can add a tag to. So the
tag goes and the migration is the only thing that creates it.

No database is affected. The initial table migration AutoMigrates a frozen
snapshot of this model that has neither app_code nor seed_code, and nothing
else in the repository AutoMigrates the live one - which is why this stayed
invisible until a test built the schema from the live model and seeded two
applications, and got a unique-constraint failure on a seed code they are
supposed to be able to share.
2026-09-09 12:35:17 +08:00
zhangwenjian 412413c12f feat✨: record which casbin policies an install created
sys_app_casbin_grant has existed since the registry tables were added and
nothing ever wrote to it. An uninstaller reading it would have found it empty,
deleted no policy at all, and reported every one of them as an unattributable
leftover - which is what "report and skip" looks like when the ledger was
simply never written, and is indistinguishable from it working.

grantToAdminRole now writes an entry for each policy it creates. The entry
carries the tuple casbin_rule is unique on rather than a foreign key into it,
because casbin_rule is not this project's table: the gorm adapter's SavePolicy
truncates it and writes it back from memory, and SysRole.Update replaces a
role's policy rows wholesale. Both rebuild the same tuple from the same
sys_menu/sys_api data, so a match on the tuple survives what a row id does
not.

Only policies this install actually created are recorded - the insert is
conditional and its RowsAffected says which. A policy that was already there
was granted by somebody else and is not this app's to take away.

The two ways that can be wrong are not equally bad, which is what settles it.
Under-recording leaves a policy behind and the uninstall says so, because a
policy naming this app's own path with no ledger entry is exactly what it
reports as an orphan. Over-recording deletes somebody's authorization,
silently. Between a visible leftover and an invisible deletion, take the
leftover.

The ledger insert is itself conditional, for a case the obvious retry test
does not reach: on a plain re-run the policy still exists, so the insert is
skipped before the ledger is touched. It is reached when the policy row was
removed while its entry stayed, and a plain insert would then abort the whole
seed on the ledger's unique index. There is a test for that specific shape,
and replacing the insert with a plain one turns it red - which the plain retry
test does not.

Ordering: the ledger table is created by a framework migration, and version
strings sort bare digits ahead of any app-prefixed one, so it exists before
any application's seed runs. Nothing in the framework's own migrations calls
SeedMenus.
2026-09-09 12:27:38 +08:00
zhangwenjian 35d213f339 feat✨: install one application from its manifest
`migrate install <code>` brings one application up to the version its manifest
declares: it runs that application's outstanding migrations and records what
it did in sys_app.

It goes under migrate rather than under the existing `app` command, which
already means "generate the skeleton of a new application" - a directory that
does not exist yet, not an application already compiled into this binary.
Installing one is running its migrations, which is what this command is, so
--domain, resolveDB and the guard that refuses a mistyped code instead of
reporting a successful no-op are all already here.

Three phases, each committing on its own, and they are not one transaction.
An application's versions are separate migration files, and on MySQL a DDL
statement commits the transaction around it - destroying an outer transaction
and every savepoint taken from it. So this does not promise that a
half-installed application cannot happen. It promises one is visible when it
does: phase A writes "installing" before anything that can fail, phase B runs
the migrations, phase C turns that into "installed" or into "failed" with the
version it stopped on.

What is left to apply comes from sys_migration, never from sys_app. sys_app
is a derived view - a summary, and the answer to "which version does this app
think it is at". If it were the authority, an operator who deleted
sys_migration rows by hand would be told an application is installed while its
schema is not, which is worse than not knowing. So "already installed, nothing
to do" needs all three: nothing outstanding, recorded as installed, and the
same version. A row stuck at "installing" - what it reads as after the process
was killed partway - is not installed, and retrying is just running the
command again.

An upgrade is in place and keeps the first install's time; a downgrade is
refused, and refused before phase A writes anything, so a refusal cannot cost
the operator the row that told them what they had. An unparseable recorded
version is refused the same way, while it is still readable.

The report ends by saying the code is not running yet. That is not a
pleasantry: Go links at build time and Vite resolves its import globs at build
time, so installing an application writes its menus, its APIs and its
permissions and cannot make one line of its code run - and the menus appearing
is exactly what makes an operator believe otherwise.

Ten degradations were applied one at a time to check the tests name the
behaviour rather than the shape: deciding the no-op from sys_app alone,
always writing installed_at, allowing the downgrade, keeping the previous
attempt's diagnostics on a row that now says installed, treating "installing"
as installed, truncating last_error by bytes so a Chinese message is cut
mid-rune, not recording the failure at all, skipping code normalization, and
writing phase A before either the downgrade or the version-parse refusal.
Each went red on the assertion it was aimed at. An eleventh was discarded
rather than counted: it failed in the first install's setup, not on the claim.
2026-09-09 12:22:16 +08:00
zhangwenjian 9c68bc25a5 refactor♻️: report a failed migration instead of ending the process
run() called log.Fatalf on the first migration that failed, which ended the
process from inside the migration engine. Nothing above it could record what
happened - an installer needs to write down which version an attempt stopped
on - and no test could exercise a failing migration at all without taking the
test binary with it, which is why the one test that covers a failed migration
drove the registered function directly and left the scheduler uncovered.

run(), Migrate() and MigrateApp() now return an error, and the exit moved to
the command layer where the exit code is the command's business.

Two of those errors say more than "it failed". A migration that fails comes
back as a *VersionFailure naming the version, because an installer records
that as a diagnostic snapshot - the authoritative answer to where a retry
resumes is always recomputed from sys_migration, never read back, and asking
the database what is still pending answers a different question that merely
has the same answer most of the time. An app code nothing registered under is
now an error rather than a log line, so an installer asking for one app by
name cannot be told that installing an app that does not exist succeeded; the
command layer still rejects a typo before any database work.

exitOnError is what makes the command exit non-zero, and it covers more than
it replaces. Every path out of migrateModel used to return without an exit
code: an unreachable tenant database or a failed AutoMigrate printed a line
and exited 0, so a caller that migrates before starting a server - the deploy
workflow does exactly that - carried on onto a schema that had not been
brought forward. A failing migration function was the only failure reported,
and only as a side effect of the log.Fatalf this commit removes.

Each of these was checked by degrading it and watching the named assertion
go red: returning nil instead of the failure, naming the first version rather
than the one that failed, accepting an unregistered app code, and not exiting.

One gap is left open deliberately. Go allows a call whose only result is an
error to stand as a statement, so `migration.Migrate.Migrate()` still compiles
while dropping what it returns - `go build` passed while migrateModel was
doing exactly that during this change. Both call sites now return the value,
which the compiler does check, but nothing guards against the statement form
coming back. A checksilent rule was considered and dropped: that tool parses
without type information, so it could only match the method name, and a guard
that fires on any type with a Migrate method is noise.
2026-09-09 12:14:12 +08:00
wenjianzhang 9c5d9d16a7 Merge pull request #925 from go-admin-team/fix/deploy-image-bloat
Stop the demo host filling up with old images
2026-09-09 10:54:08 +08:00
zhangwenjian 46c10f999a ci👷: reclaim before the pull, not only after a healthy deploy
Cleaning up after a successful deployment never runs on the host that needs it.
The pull is the first thing in this script that needs space and it is where a
full disk stops it, so the run ends before reaching any cleanup - and so does
the next run, and the one after that. That is not hypothetical: a deployment
failed on the pull with no space left on the device, and rerunning the workflow
unchanged failed at the same place. The disk had to be cleared by hand before a
deployment could go through.

The pipeline is now a function called twice, before the pull and after the
health check, so the window is bounded on both sides.

Verified against a real docker daemon, in the function form rather than the
inlined one: with five images newer than the running one, so position alone no
longer protects it, it leaves three and does not select the live one; removing
the id exclusion from the same function does select it. With NAME pointing at a
container that does not exist it selects nothing - as it also does without the
explicit guard, which is there because grep -v on an empty id reads like the
opposite of what it does, not because it changes the outcome.
2026-09-09 08:11:26 +08:00
zhangwenjian d12f40c9a0 ci👷: remove this repository's old images after a healthy deploy
Every deployment pulls an image tagged with its commit and nothing removed the
previous one, so they only accumulated. 68 had built up when a deployment failed
on a pull with no space left on the device. That is the harmless place to fail -
the site kept serving the image it already had - but no later run would have
recovered on its own.

Three are kept so a release can be re-run by tag by hand. Only this repository's
images are listed, because the host runs other services. The image the new
container is on is excluded by id rather than by position, and rmi is called
without -f so an image a container still holds is refused rather than taken from
it.

Verified against a real docker daemon: with five images newer than the running
one, so position alone no longer protects it, the pipeline leaves three and does
not select the live one. Removing the id exclusion from the same pipeline does
select it, so that guard is load-bearing rather than decorative.
2026-09-09 07:45:51 +08:00
zhangwenjian cd363fce3d perf👌: stop shipping a C toolchain in the runtime image
gcc and g++ were 273MB of a 381MB image, and nothing in the container ever
invoked them: the binary is compiled and statically linked before the image is
built and arrives as a COPY, and Go is not installed here either.

The layer cost more than its size. apk resolves against an index that moves, so
its digest differed on every build and no two images shared it - a host that
keeps one image per deployed commit paid the full 273MB each time rather than
storing it once.

libc6-compat is kept although nothing measured needs it: a container built
without it resolves a hostname and opens a database connection exactly as one
built with it, but it costs half a megabyte and covers a ./main that was linked
dynamically, which this Dockerfile cannot check.

Verified by building this Dockerfile and running the result under the check the
deploy script uses - captcha answering 200 and the log reporting the datastore
connected. A control built from the current recipe passes the same check and
carries a 273MB apk layer this one does not; a third build with a deliberately
truncated binary fails the check, so it distinguishes a serving process from a
dead one.
2026-09-09 07:45:40 +08:00
Jack Walker 709cebd4a7 fix🐛: return an error when stopping a job times out
Fixes #890
2026-09-08 19:26:49 -04:00
wenjianzhang ba5ef9f79c Merge pull request #923 from go-admin-team/feat/008-host-schema
008: the application registry, its natural keys, and an idempotent seed
2026-09-08 20:35:28 +08:00
80 changed files with 6645 additions and 749 deletions
+38
View File
@@ -117,7 +117,41 @@ jobs:
test -f "$CFG" || { echo "宿主机配置缺失,中止部署"; exit 1; }
# Old images of this repository are removed here and nowhere else.
# Every deployment pulls one tagged with its commit and nothing ever
# removed the previous one, so they only accumulated: 68 of them
# filled the disk and the next deployment could not pull.
#
# Three are kept so a release can be re-run by tag by hand.
#
# Only this repository's images are listed, because the host runs
# other services whose images are not this script's business. The
# image the live container is on is excluded by id rather than by
# position, so it survives even if the listing order is not what
# it looks like. With no container to ask, the function returns
# rather than running the pipeline on an empty id - which would
# also delete nothing, but by way of grep -v matching every line,
# which reads like the opposite of what it does. No -f, so an image
# any container still holds - including the one kept for rollback -
# is refused rather than taken away from it.
prune_old_images() {
REPO="${IMG%:*}"
LIVE=$(sudo docker inspect -f '{{.Image}}' "$NAME" 2>/dev/null | sed 's/^sha256://' | cut -c1-12)
[ -n "$LIVE" ] || return 0
sudo docker images "$REPO" --format '{{.ID}} {{.Repository}}:{{.Tag}}' \
| grep -v "^$LIVE" \
| tail -n +3 \
| awk '{print $2}' \
| xargs -r -n1 sudo docker rmi >/dev/null 2>&1 || true
}
sudo docker login --username=${{ secrets.DOCKER_USERNAME }} registry.ap-northeast-1.aliyuncs.com --password=${{ secrets.DOCKER_PASSWORD }}
# Before the pull, not only after a successful deploy. The pull
# is the first thing here that needs space and it is where a full
# disk stops this script, so a cleanup that only runs afterwards
# never runs on the host that needs it: rerunning the workflow
# fails at the same pull, and the disk has to be cleared by hand.
prune_old_images
sudo docker pull "$IMG" || { echo "拉取镜像失败,中止部署"; exit 1; }
# 迁移用新镜像跑。失败时线上仍是旧版本配旧 schema,是自洽的;
@@ -155,6 +189,10 @@ jobs:
if [ "$ok" = "1" ]; then
sudo docker rm -f "$PREV" >/dev/null 2>&1 || true
# Again, so the image this deployment replaced falls out of the
# window rather than waiting for the next deployment to notice.
prune_old_images
else
echo "健康检查失败,回滚到上一版本"
sudo docker logs --tail 40 "$NAME" 2>&1 || true
+46
View File
@@ -46,6 +46,28 @@ jobs:
--health-timeout 3s
--health-retries 10
# The fourth registered driver, and the one that disagrees with the
# other three about NULL: its unique index treats two NULLs as equal and
# permits one. A migration that builds a unique index over a nullable
# column therefore fails here and nowhere else, which is how one shipped
# that no SQL Server database could apply at all - not even an empty
# one. The password is this container's only credential and the
# container lives for the length of one job.
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
env:
ACCEPT_EULA: "Y"
MSSQL_SA_PASSWORD: GoAdmin_Test1
MSSQL_PID: Developer
ports:
- 1433:1433
options: >-
--health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P GoAdmin_Test1 -C -Q 'SELECT 1'"
--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
@@ -54,6 +76,7 @@ jobs:
# migration that failed on every PostgreSQL database it was pointed at.
# 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"
steps:
@@ -66,9 +89,23 @@ jobs:
- name: Check out code into the Go module directory
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# SQL Server has no equivalent of POSTGRES_DB, so the database the DSN
# names has to be created before the tests run.
- name: Create the SQL Server test database
run: |
docker exec ${{ job.services.sqlserver.id }} /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P GoAdmin_Test1 -C \
-Q "IF DB_ID('goadmin_test') IS NULL CREATE DATABASE goadmin_test"
- 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.
@@ -78,6 +115,15 @@ jobs:
- name: Build
run: make build
# A separate module, so none of the steps above see it: the main module's
# go.mod, its build and its tests are all unaware of the example
# application. This is the only thing that exercises an application being
# installed at all - everything below it runs against an injected engine
# and a hand-built schema, and none of that can catch an application's
# init() reaching one registry and not the other.
- name: End-to-end install and uninstall
run: make test-e2e
# Fails the build on the silent-failure classes listed in
# tools/checksilent, one of which is the contract boundary: nothing under
# common/ may import app/. A boundary that is only written down erodes; this
+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 岗位列表
+21 -5
View File
@@ -4,10 +4,26 @@ FROM alpine
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories
RUN apk update --no-cache
RUN apk add --update gcc g++ libc6-compat
RUN apk add --no-cache ca-certificates
RUN apk add --no-cache tzdata
# Runtime packages only.
#
# gcc and g++ used to be installed here and were 273MB of a 381MB image. The
# binary this image runs is compiled and linked before the image is built and
# arrives as a COPY, so nothing in the container ever invokes a compiler -
# there is no toolchain to drive it with either, since Go itself is not
# installed.
#
# That layer was also why a host could not share storage between images. apk
# resolves against an index that moves, so the layer digest differed on every
# build and no two images shared it: a host keeping one image per deployed
# commit stored a private 273MB copy each time. 68 of them filled the disk
# and the next deployment could not pull.
#
# libc6-compat stays. Nothing measured needs it - the binary CI produces is
# statically linked, and a container built without libc6-compat resolves a
# hostname and opens a database connection exactly as one built with it - but
# it is half a megabyte and it covers a ./main that was linked dynamically,
# which this Dockerfile has no way to check.
RUN apk add --no-cache ca-certificates tzdata libc6-compat
ENV TZ Asia/Shanghai
COPY ./main /main
@@ -15,4 +31,4 @@ COPY ./config/settings.demo.yml /config/settings.yml
COPY ./go-admin-db.db /go-admin-db.db
EXPOSE 8000
RUN chmod +x /main
CMD ["/main","server","-c", "/config/settings.yml"]
CMD ["/main","server","-c", "/config/settings.yml"]
+29
View File
@@ -54,6 +54,18 @@ stop:
test:
go test -race -cover ./...
# The end-to-end install, which `test` above cannot reach: test/e2e-apporder
# is its own module, so `./...` in this one does not include it. It builds a
# go-admin binary with the example application linked in and drives
# `migrate install` / `migrate uninstall` against a real database.
#
# Its own target rather than a line in the CI workflow, so the one thing in
# the build that exercises installing an application is also the one thing
# somebody can run before pushing.
.PHONY: test-e2e
test-e2e:
cd test/e2e-apporder && go test ./... -count=1
# Reports the failures that do not announce themselves - see
# tools/checksilent. Exits non-zero on an ERROR; the one WARN-level check
# prints and does not fail the build.
@@ -68,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
-6
View File
@@ -277,15 +277,11 @@ pnpm dev
<span style="margin: 0 5px;" ><a href="https://github.com/ninstein" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/580303?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/kikiyou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/17959053?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/horizonzy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/22524871?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Cassuis" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/48005724?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/hqcchina" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/5179057?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/nodece" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16235121?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/stephenzhang0713" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/18169290?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/zhouxixi-dev" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/100399679?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Jalins" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31172582?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/wkf928592" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6063351?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/wxxiong6" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6983441?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Silicon-He" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/52478309?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/GizmoOAO" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20385106?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/bestgopher" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/36840497?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/wxb1207" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20775558?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
@@ -299,8 +295,6 @@ pnpm dev
<span style="margin: 0 5px;" ><a href="https://github.com/infnan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/38274826?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/d1y" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45585937?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/qlijin" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/515900?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/logtous
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/88697234?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/stepway
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/9927079?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/NaturalGao
-6
View File
@@ -277,15 +277,11 @@ pnpm dev
<span style="margin: 0 5px;" ><a href="https://github.com/ninstein" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/580303?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/kikiyou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/17959053?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/horizonzy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/22524871?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Cassuis" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/48005724?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/hqcchina" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/5179057?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/nodece" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16235121?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/stephenzhang0713" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/18169290?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/zhouxixi-dev" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/100399679?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Jalins" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31172582?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/wkf928592" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6063351?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/wxxiong6" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6983441?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Silicon-He" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/52478309?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/GizmoOAO" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20385106?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/bestgopher" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/36840497?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/wxb1207" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20775558?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
@@ -299,8 +295,6 @@ pnpm dev
<span style="margin: 0 5px;" ><a href="https://github.com/infnan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/38274826?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/d1y" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45585937?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/qlijin" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/515900?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/logtous
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/88697234?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/stepway
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/9927079?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/NaturalGao
-6
View File
@@ -263,15 +263,11 @@ pnpm dev
<span style="margin: 0 5px;" ><a href="https://github.com/ninstein" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/580303?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/kikiyou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/17959053?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/horizonzy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/22524871?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Cassuis" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/48005724?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/hqcchina" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/5179057?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/nodece" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16235121?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/stephenzhang0713" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/18169290?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/zhouxixi-dev" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/100399679?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Jalins" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31172582?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/wkf928592" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6063351?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/wxxiong6" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6983441?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Silicon-He" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/52478309?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/GizmoOAO" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20385106?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/bestgopher" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/36840497?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/wxb1207" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20775558?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
@@ -285,8 +281,6 @@ pnpm dev
<span style="margin: 0 5px;" ><a href="https://github.com/infnan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/38274826?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/d1y" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45585937?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/qlijin" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/515900?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/logtous
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/88697234?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/stepway
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/9927079?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/NaturalGao
-6
View File
@@ -277,15 +277,11 @@ pnpm dev
<span style="margin: 0 5px;" ><a href="https://github.com/ninstein" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/580303?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/kikiyou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/17959053?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/horizonzy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/22524871?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Cassuis" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/48005724?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/hqcchina" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/5179057?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/nodece" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16235121?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/stephenzhang0713" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/18169290?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/zhouxixi-dev" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/100399679?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Jalins" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31172582?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/wkf928592" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6063351?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/wxxiong6" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6983441?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Silicon-He" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/52478309?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/GizmoOAO" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20385106?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/bestgopher" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/36840497?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/wxb1207" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20775558?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
@@ -299,8 +295,6 @@ pnpm dev
<span style="margin: 0 5px;" ><a href="https://github.com/infnan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/38274826?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/d1y" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45585937?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/qlijin" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/515900?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/logtous
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/88697234?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/stepway
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/9927079?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/NaturalGao
+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(), "删除成功")
}
}
+17
View File
@@ -6,6 +6,23 @@ import (
"go-admin/common/models"
)
// The values sys_app.status takes.
//
// Three states rather than a single "installed", because an install that
// stopped partway has to be an observable row rather than the absence of one:
// the versions an app installs are separate migration files, and on MySQL a
// DDL statement commits the transaction around it - taking an outer
// transaction and every savepoint under it with it - so they cannot be
// wrapped in one.
//
// AppInstalling is also what a row reads as after the process was killed
// mid-install, which is why it is not treated as "installed" by anything.
const (
AppInstalling = 1
AppInstalled = 2
AppFailed = 3
)
// SysApp is the sys_app row model: one row per installed application (PRD
// 008 F2). It deliberately does not embed models.ModelTime - see the design
// doc (docs-prd/008-应用清单与安装器/数据库变更.md) §1.1 for why an
+12 -1
View File
@@ -44,7 +44,18 @@ type SysMenu struct {
// unique index below: the database never treats two NULLs as equal, so
// only rows that do carry a real code participate in the uniqueness
// check at all.
SeedCode *string `json:"seedCode" gorm:"size:64;uniqueIndex:uk_sys_menu_app_seed_code_del;comment:raw MenuSpec.Code, null for rows not written through SeedMenus"`
// uk_sys_menu_app_seed_code_del is created by the migration, not from
// this tag, and deliberately: it covers (app_code, seed_code,
// deleted_at), and this struct cannot say so. A named uniqueIndex tag
// puts every field carrying that name into one index, and deleted_at
// comes from the shared ModelTime embed, which no single model can add a
// tag to. Naming it here anyway declared a unique index on seed_code
// alone under the same name - stricter than the real one, forbidding two
// applications from both having a "dir" node - and AutoMigrate on this
// model would have created that one first, after which the migration's
// HasIndex guard finds the name taken and leaves the wrong index in
// place.
SeedCode *string `json:"seedCode" gorm:"size:64;comment:raw MenuSpec.Code, null for rows not written through SeedMenus"`
models.ControlBy
models.ModelTime
}
@@ -0,0 +1,49 @@
package models
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func ptr(s string) *string { return &s }
// uk_sys_menu_app_seed_code_del covers (app_code, seed_code, deleted_at) and
// is created by 1786700008000, not from a struct tag. It cannot come from a
// tag: a named uniqueIndex collects every field carrying that name, and
// deleted_at lives in the shared ModelTime embed that no single model can tag.
//
// Naming it on SeedCode alone anyway produced a unique index on seed_code by
// itself under the same name - stricter than the real one - and AutoMigrate
// here would create that one, after which the migration's HasIndex guard
// finds the name taken and leaves the wrong index in place. Nothing in
// production AutoMigrates this model (the initial table migration uses a
// frozen snapshot that has neither column), which is why this never showed up
// as a broken database; it showed up the first time a test built the schema
// from the live model and seeded two applications.
func TestSysMenuDeclaresNoSeedCodeIndexOfItsOwn(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open: %v", err)
}
if err := db.AutoMigrate(&SysMenu{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
if db.Migrator().HasIndex(&SysMenu{}, "uk_sys_menu_app_seed_code_del") {
t.Error("AutoMigrate created uk_sys_menu_app_seed_code_del from a tag; " +
"the migration's HasIndex guard will now skip the composite index it should create")
}
// Two applications, the same seed code. The real index allows it because
// app_code is part of the key; an index on seed_code alone does not.
for _, app := range []string{"order", "crm"} {
row := SysMenu{MenuName: app + "Dir", AppCode: app, SeedCode: ptr("dir")}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("%s could not use the seed code \"dir\": %v", app, err)
}
}
}
+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
}
}
+206 -37
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"strconv"
"strings"
"time"
"gorm.io/gorm"
@@ -76,7 +77,7 @@ func (adminSeeder) SeedMenus(tx *gorm.DB, appCode string, menus []seed.MenuSpec,
if len(menuIDs) == 0 && len(apiRows) == 0 {
return nil
}
return grantToAdminRole(tx, menuIDs, apiRows)
return grantToAdminRole(tx, appCode, menuIDs, apiRows)
}
// seedApis writes one sys_api row per ApiSpec and returns them keyed by
@@ -209,10 +210,24 @@ func seedMenuTree(tx *gorm.DB, appCode string, specs []seed.MenuSpec, apiRows ma
// the soft-delete plugin scopes deleted_at = 0 automatically on
// every query against models.SysMenu.
var existing models.SysMenu
found := false
err := tx.Where("app_code = ? AND seed_code = ?", appCode, s.Code).First(&existing).Error
switch {
case err == nil:
row, err := repairExistingMenu(tx, existing, s, parentRow, apiRows)
found = true
case errors.Is(err, gorm.ErrRecordNotFound):
// Nothing under the natural key. It may still be here from
// before seed_code existed, under the name that identified
// it then.
existing, found, err = adoptLegacyMenu(tx, appCode, s)
if err != nil {
return nil, fmt.Errorf("%q: %w", s.Code, err)
}
default:
return nil, fmt.Errorf("%q: checking for an existing row: %w", s.Code, err)
}
if found {
row, err := repairExistingMenu(tx, existing, appCode, s, parentRow, apiRows)
if err != nil {
return nil, fmt.Errorf("%q: repairing an existing row: %w", s.Code, err)
}
@@ -220,32 +235,9 @@ func seedMenuTree(tx *gorm.DB, appCode string, specs []seed.MenuSpec, apiRows ma
ids = append(ids, row.MenuId)
progressed = true
continue
case errors.Is(err, gorm.ErrRecordNotFound):
// Not written yet; fall through to create it below.
default:
return nil, fmt.Errorf("%q: checking for an existing row: %w", s.Code, err)
}
seedCode := s.Code
row := models.SysMenu{
MenuName: menuName(appCode, s.Code),
Title: s.Title,
Icon: s.Icon,
Path: s.Path,
MenuType: s.Kind,
Permission: s.Permission,
ParentId: parentRow.MenuId,
Component: s.Component,
Sort: s.Sort,
// Visible "0" is shown, not hidden - the same defaults
// 1786700001000_demo_menu.go seeds its own menu with. A
// freshly installed application's menu should not need an
// administrator to first find and unhide it.
Visible: "0",
IsFrame: "1",
AppCode: appCode,
SeedCode: &seedCode,
}
row := menuRowFor(appCode, s, parentRow)
for _, code := range s.ApiCodes {
api, ok := apiRows[code]
if !ok {
@@ -344,15 +336,27 @@ func expectedPaths(menuID int, parent string, parentRow models.SysMenu) string {
// here just as much as it does there. Reconciling stale seed-driven
// bindings, if it is ever wanted, belongs in the upgrade path with that
// same ownership check - not silently inside every retry of every install.
func repairExistingMenu(tx *gorm.DB, existing models.SysMenu, s seed.MenuSpec, parentRow models.SysMenu, apiRows map[string]models.SysApi) (models.SysMenu, error) {
want := expectedPaths(existing.MenuId, s.Parent, parentRow)
if existing.Paths != want {
if err := tx.Model(&models.SysMenu{}).Where("menu_id = ?", existing.MenuId).
Update("paths", want).Error; err != nil {
return models.SysMenu{}, fmt.Errorf("repairing paths: %w", err)
}
existing.Paths = want
func repairExistingMenu(tx *gorm.DB, existing models.SysMenu, appCode string, s seed.MenuSpec, parentRow models.SysMenu, apiRows map[string]models.SysApi) (models.SysMenu, error) {
// Every column the spec decides, not just the two this used to touch. A
// menu whose parent was removed and reseeded kept parent_id pointing at
// the dead row while its paths named the new one, and the tree is built
// from parent_id - so the menu vanished from the sidebar with the
// migration reporting success. An application that renamed a menu or
// moved its component between versions had its change silently ignored
// for the same reason: nothing here wrote those columns.
want := menuRowFor(appCode, s, parentRow)
if err := tx.Model(&models.SysMenu{}).Where("menu_id = ?", existing.MenuId).
Select(specMenuFields).Updates(want).Error; err != nil {
return models.SysMenu{}, fmt.Errorf("bringing the row up to the spec: %w", err)
}
want.MenuId = existing.MenuId
want.Paths = existing.Paths
want.Visible, want.IsFrame = existing.Visible, existing.IsFrame
if err := repairPaths(tx, &want, s, parentRow); err != nil {
return models.SysMenu{}, err
}
existing = want
for _, code := range s.ApiCodes {
api, ok := apiRows[code]
@@ -422,7 +426,7 @@ func pascalCase(s string) string {
// framework migration sorts before every app-prefixed one - means that
// should not happen in practice, but failing this call over it would be
// worse than a menu with no grant yet.
func grantToAdminRole(tx *gorm.DB, menuIDs []int, apiRows map[string]models.SysApi) error {
func grantToAdminRole(tx *gorm.DB, appCode string, menuIDs []int, apiRows map[string]models.SysApi) error {
var role models.SysRole
if err := tx.Where("role_key = ?", adminRoleKey).First(&role).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -441,12 +445,177 @@ func grantToAdminRole(tx *gorm.DB, menuIDs []int, apiRows map[string]models.SysA
}
for _, a := range apiRows {
if err := tx.Exec(
res := tx.Exec(
"INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) SELECT 'p', ?, ?, ?, '', '', '' WHERE NOT EXISTS (SELECT 1 FROM casbin_rule WHERE ptype='p' AND v0=? AND v1=? AND v2=?)",
role.RoleKey, a.Path, a.Action, role.RoleKey, a.Path, a.Action,
).Error; err != nil {
)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
// The policy was already there, so this install did not create
// it and it is not this app's to take away. Leaving it out of
// the ledger is what makes an uninstall report it instead of
// deleting it.
//
// The two ways this can be wrong are not equally bad, which is
// what settles it. Under-recording leaves a policy behind and
// the uninstall says so, because a policy naming an app's own
// path with no ledger entry is exactly what it lists as an
// orphan. Over-recording deletes a grant somebody else made,
// silently. Between a visible leftover and an invisible
// deletion of somebody's authorization, take the leftover.
continue
}
if err := recordGrant(tx, appCode, role.RoleKey, a.Path, a.Action); err != nil {
return err
}
}
return nil
}
// recordGrant writes down that this application's install created one casbin
// policy, keyed by the same tuple casbin_rule is unique on.
//
// A ledger rather than a column on casbin_rule, because casbin_rule is not
// this project's table: the gorm adapter's SavePolicy truncates it and writes
// it back from memory, which would drop any column added here, and
// SysRole.Update replaces a role's policy rows wholesale. The tuple survives
// both, because both rebuild it from the same sys_menu/sys_api data.
//
// Written with the same INSERT ... WHERE NOT EXISTS shape as the policy above
// rather than a plain insert: the ledger's unique index covers the tuple
// alone, so a duplicate would abort the whole seed instead of being the
// no-op it should be.
func recordGrant(tx *gorm.DB, appCode, roleKey, path, action string) error {
return tx.Exec(
"INSERT INTO sys_app_casbin_grant (app_code, ptype, v0, v1, v2, v3, v4, v5, created_at) "+
"SELECT ?, 'p', ?, ?, ?, '', '', '', ? WHERE NOT EXISTS "+
"(SELECT 1 FROM sys_app_casbin_grant WHERE ptype='p' AND v0=? AND v1=? AND v2=? AND v3='' AND v4='' AND v5='')",
appCode, roleKey, path, action, time.Now(), roleKey, path, action,
).Error
}
// specMenuFields are the sys_menu columns a MenuSpec decides, and the only
// ones a reseed rewrites on a row that is already there.
//
// Visible and IsFrame are not in the list. They are seeding defaults the
// application never expressed, so an administrator who hid a seeded menu
// keeps it hidden. app_code and seed_code are not either: they are the
// natural key the row was found by, and writing them back would be writing
// what was just matched.
var specMenuFields = []string{
"MenuName", "Title", "Icon", "Path", "MenuType",
"Permission", "ParentId", "Component", "Sort",
}
// menuRowFor is the row a MenuSpec describes. One definition, so the insert
// path and the repair path cannot drift into disagreeing about what a spec
// decides.
func menuRowFor(appCode string, s seed.MenuSpec, parentRow models.SysMenu) models.SysMenu {
seedCode := s.Code
return models.SysMenu{
MenuName: menuName(appCode, s.Code),
Title: s.Title,
Icon: s.Icon,
Path: s.Path,
MenuType: s.Kind,
Permission: s.Permission,
ParentId: parentRow.MenuId,
Component: s.Component,
Sort: s.Sort,
// Visible "0" is shown, not hidden - the same defaults
// 1786700001000_demo_menu.go seeds its own menu with. A freshly
// installed application's menu should not need an administrator to
// first find and unhide it. Only written when the row is created;
// see specMenuFields.
Visible: "0",
IsFrame: "1",
AppCode: appCode,
SeedCode: &seedCode,
}
}
// repairPaths writes row.Paths, and moves whatever is underneath it.
//
// The subtree matters because it is not all in this call's specs: a menu an
// administrator added under a seeded one keeps the old prefix, and nothing
// else in the codebase would ever rewrite it. SysMenu.Update does the same
// cascade for the same column when somebody moves a menu by hand.
//
// The predicate is the row itself or a row strictly under it, rather than
// `paths LIKE old || '%'`, which also matches /0/10 when old is /0/1.
func repairPaths(tx *gorm.DB, row *models.SysMenu, s seed.MenuSpec, parentRow models.SysMenu) error {
want := expectedPaths(row.MenuId, s.Parent, parentRow)
old := row.Paths
if old == want {
return nil
}
if old == "" {
// A row whose paths was never written - an interrupted create. It
// has no subtree to speak of, and `LIKE '/%'` would match the whole
// table.
if err := tx.Model(&models.SysMenu{}).Where("menu_id = ?", row.MenuId).
Update("paths", want).Error; err != nil {
return fmt.Errorf("writing paths: %w", err)
}
row.Paths = want
return nil
}
var subtree []models.SysMenu
if err := tx.Where("paths = ? OR paths LIKE ?", old, old+"/%").Find(&subtree).Error; err != nil {
return fmt.Errorf("reading the subtree under %s: %w", old, err)
}
for _, d := range subtree {
moved := want + strings.TrimPrefix(d.Paths, old)
if err := tx.Model(&models.SysMenu{}).Where("menu_id = ?", d.MenuId).
Update("paths", moved).Error; err != nil {
return fmt.Errorf("moving %d from %s to %s: %w", d.MenuId, d.Paths, moved, err)
}
}
row.Paths = want
return nil
}
// adoptLegacyMenu claims a row this application wrote before sys_menu had a
// seed_code column, so a reseed repairs it instead of inserting a second copy
// beside it.
//
// 1786700008000 added the column and left it NULL on every row already there,
// which is right for the host's own hand-placed menus - there is nothing to
// derive one from. An application's rows are in that population too, and for
// those the value is derivable, because menu_name is what identified them
// before the column existed. Without this the natural-key lookup misses them,
// the seed inserts a duplicate, and the unique index cannot object: NULL
// never collides.
//
// Ambiguity is refused rather than guessed. menuName concatenates two
// pascalCase strings and pascalCase is not injective, so two specs can land
// on one name; picking one of several rows would attach an application's
// menu to whichever the database returned first.
func adoptLegacyMenu(tx *gorm.DB, appCode string, s seed.MenuSpec) (models.SysMenu, bool, error) {
name := menuName(appCode, s.Code)
var rows []models.SysMenu
if err := tx.Where("app_code = ? AND menu_name = ? AND seed_code IS NULL", appCode, name).
Find(&rows).Error; err != nil {
return models.SysMenu{}, false, fmt.Errorf("looking for a row written before seed_code existed: %w", err)
}
switch len(rows) {
case 0:
return models.SysMenu{}, false, nil
case 1:
default:
return models.SysMenu{}, false, fmt.Errorf(
"%d rows carry menu_name %q with no seed_code; which of them belongs to %q cannot be decided here, because menuName is not reversible - reconcile them by hand",
len(rows), name, s.Code)
}
seedCode := s.Code
if err := tx.Model(&models.SysMenu{}).Where("menu_id = ?", rows[0].MenuId).
Update("seed_code", seedCode).Error; err != nil {
return models.SysMenu{}, false, fmt.Errorf("claiming the row written before seed_code existed: %w", err)
}
rows[0].SeedCode = &seedCode
return rows[0], true, nil
}
+411 -1
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"sync"
@@ -32,7 +33,14 @@ func newSeedTestDB(t *testing.T) *gorm.DB {
if err != nil {
t.Fatalf("open: %v", err)
}
if err := db.AutoMigrate(&models.SysMenu{}, &models.SysApi{}, &models.SysRole{}); err != nil {
// sys_app_casbin_grant is where grantToAdminRole records which policies
// this install created, so an uninstall can tell them from the ones
// somebody granted by hand. In a real database it is created by
// 1786700007000, which is a framework migration and therefore runs ahead
// of every application's - version strings sort bare digits before any
// app-prefixed one.
if err := db.AutoMigrate(&models.SysMenu{}, &models.SysApi{}, &models.SysRole{},
&models.SysAppCasbinGrant{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
if err := db.Exec(`CREATE TABLE casbin_rule (
@@ -841,3 +849,405 @@ func TestSeedMenusPreservesAHandAddedBinding(t *testing.T) {
t.Errorf("the seed's own binding count = %d, want 1 - it must survive the retry too", n)
}
}
// Every policy grantToAdminRole creates has to be written down, or an
// uninstall has no way to tell this app's grants from a hand-made one and
// leaves all of them behind.
func TestSeedMenusRecordsTheGrantsItCreated(t *testing.T) {
db := newSeedTestDB(t)
role := seedAdminRole(t, db)
apis := []seed.ApiSpec{
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"},
{Code: "create", Title: "Create order", Path: "/api/v1/order", Method: "POST", Handle: "apis.Order.Insert-fm"},
}
if err := (adminSeeder{}).SeedMenus(db, "order", nil, apis); err != nil {
t.Fatalf("SeedMenus: %v", err)
}
for _, a := range apis {
var n int64
db.Model(&models.SysAppCasbinGrant{}).
Where("app_code = ? AND ptype = 'p' AND v0 = ? AND v1 = ? AND v2 = ?",
"order", role.RoleKey, a.Path, a.Method).
Count(&n)
if n != 1 {
t.Errorf("ledger rows for %s %s = %d, want 1", a.Method, a.Path, n)
}
}
}
// A policy that was already there was not created by this install, so it is
// not this app's to take away later. Recording it would mean an uninstall
// deletes a grant somebody else made, and deletes it silently - the opposite
// mistake leaves a policy behind, which the uninstall reports.
func TestSeedMenusDoesNotClaimAPolicyItDidNotCreate(t *testing.T) {
db := newSeedTestDB(t)
role := seedAdminRole(t, db)
if err := db.Exec(
"INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', ?, '/api/v1/order', 'GET', '', '', '')",
role.RoleKey,
).Error; err != nil {
t.Fatalf("pre-existing policy: %v", err)
}
apis := []seed.ApiSpec{
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"},
{Code: "create", Title: "Create order", Path: "/api/v1/order", Method: "POST", Handle: "apis.Order.Insert-fm"},
}
if err := (adminSeeder{}).SeedMenus(db, "order", nil, apis); err != nil {
t.Fatalf("SeedMenus: %v", err)
}
var claimed int64
db.Model(&models.SysAppCasbinGrant{}).
Where("v1 = ? AND v2 = ?", "/api/v1/order", "GET").Count(&claimed)
if claimed != 0 {
t.Errorf("the ledger claimed a policy that was already there (%d rows)", claimed)
}
// The one it did create is still recorded: the skip is per policy, not
// for the whole call.
var created int64
db.Model(&models.SysAppCasbinGrant{}).
Where("v1 = ? AND v2 = ?", "/api/v1/order", "POST").Count(&created)
if created != 1 {
t.Errorf("ledger rows for the policy it did create = %d, want 1", created)
}
// And the pre-existing policy itself is untouched.
var policies int64
db.Table("casbin_rule").Where("v1 = ? AND v2 = ?", "/api/v1/order", "GET").Count(&policies)
if policies != 1 {
t.Errorf("casbin_rule rows = %d, want the one that was already there", policies)
}
}
// A migration that failed partway is re-run whole. The ledger must come out
// of a second run the same as the first, not with a duplicate or an error
// from its own unique index.
func TestSeedMenusLedgerSurvivesARetry(t *testing.T) {
db := newSeedTestDB(t)
seedAdminRole(t, db)
apis := []seed.ApiSpec{
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"},
}
for i := 0; i < 2; i++ {
if err := (adminSeeder{}).SeedMenus(db, "order", nil, apis); err != nil {
t.Fatalf("SeedMenus run %d: %v", i+1, err)
}
}
var n int64
db.Model(&models.SysAppCasbinGrant{}).Count(&n)
if n != 1 {
t.Errorf("ledger has %d rows after two runs, want 1", n)
}
}
// The ledger's own guard against a duplicate, which the plain retry above
// never reaches: there the policy still exists, so the insert is skipped
// before the ledger is touched at all. This is the case that does reach it -
// the policy row was removed while its ledger entry stayed, so the seed
// creates the policy again and writes a ledger entry that is already there.
// A plain insert would abort the whole seed on the ledger's unique index.
func TestSeedMenusLedgerToleratesAnEntryWhosePolicyWasRemoved(t *testing.T) {
db := newSeedTestDB(t)
seedAdminRole(t, db)
apis := []seed.ApiSpec{
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"},
}
if err := (adminSeeder{}).SeedMenus(db, "order", nil, apis); err != nil {
t.Fatalf("first run: %v", err)
}
if err := db.Exec("DELETE FROM casbin_rule WHERE v1 = ? AND v2 = ?", "/api/v1/order", "GET").Error; err != nil {
t.Fatalf("removing the policy: %v", err)
}
var ledger int64
db.Model(&models.SysAppCasbinGrant{}).Count(&ledger)
if ledger != 1 {
t.Fatalf("the ledger entry is gone, so this test is not set up: %d rows", ledger)
}
if err := (adminSeeder{}).SeedMenus(db, "order", nil, apis); err != nil {
t.Fatalf("second run: %v", err)
}
db.Model(&models.SysAppCasbinGrant{}).Count(&ledger)
if ledger != 1 {
t.Errorf("ledger has %d rows, want 1", ledger)
}
var policies int64
db.Table("casbin_rule").Where("v1 = ? AND v2 = ?", "/api/v1/order", "GET").Count(&policies)
if policies != 1 {
t.Errorf("the policy was not put back: %d rows", policies)
}
}
// The tree is built from parent_id, not from paths. A menu whose parent was
// removed and written again kept parent_id on the dead row while its paths
// named the new one, so the menu was gone from the sidebar and the migration
// said it had succeeded.
func TestSeedMenusRepairsParentIdAfterTheParentWasRemoved(t *testing.T) {
db := newSeedTestDB(t)
useCompositeSeedCodeIndex(t, db)
seedAdminRole(t, db)
menus, apis := orderMenuSpecs("Orders", "apps/order/index")
if err := (adminSeeder{}).SeedMenus(db, "order", menus, apis); err != nil {
t.Fatalf("first seed: %v", err)
}
var dir models.SysMenu
if err := db.Where("app_code = ? AND seed_code = ?", "order", "dir").First(&dir).Error; err != nil {
t.Fatal(err)
}
if err := db.Delete(&models.SysMenu{}, "menu_id = ?", dir.MenuId).Error; err != nil {
t.Fatalf("removing the parent: %v", err)
}
if err := (adminSeeder{}).SeedMenus(db, "order", menus, apis); err != nil {
t.Fatalf("second seed: %v", err)
}
var newDir, list models.SysMenu
if err := db.Where("app_code = ? AND seed_code = ?", "order", "dir").First(&newDir).Error; err != nil {
t.Fatal(err)
}
if err := db.Where("app_code = ? AND seed_code = ?", "order", "list").First(&list).Error; err != nil {
t.Fatal(err)
}
if newDir.MenuId == dir.MenuId {
t.Fatal("the removed parent was reused, so this test proves nothing")
}
if list.ParentId != newDir.MenuId {
t.Errorf("parent_id = %d, want the new parent %d; the menu hangs off a row that is gone",
list.ParentId, newDir.MenuId)
}
if want := newDir.Paths + "/" + strconv.Itoa(list.MenuId); list.Paths != want {
t.Errorf("paths = %q, want %q", list.Paths, want)
}
}
// A menu somebody added under a seeded one is not in any spec, so nothing but
// this would ever rewrite its path when its ancestor moves.
func TestSeedMenusMovesTheSubtreeUnderARepairedMenu(t *testing.T) {
db := newSeedTestDB(t)
useCompositeSeedCodeIndex(t, db)
seedAdminRole(t, db)
menus, apis := orderMenuSpecs("Orders", "apps/order/index")
if err := (adminSeeder{}).SeedMenus(db, "order", menus, apis); err != nil {
t.Fatalf("first seed: %v", err)
}
var dir, list models.SysMenu
db.Where("app_code = ? AND seed_code = ?", "order", "dir").First(&dir)
db.Where("app_code = ? AND seed_code = ?", "order", "list").First(&list)
// By hand, under the seeded menu, the way an administrator would.
hand := models.SysMenu{MenuName: "HandMade", Title: "By hand", MenuType: contractmodels.Menu,
ParentId: list.MenuId}
if err := db.Create(&hand).Error; err != nil {
t.Fatal(err)
}
hand.Paths = list.Paths + "/" + strconv.Itoa(hand.MenuId)
db.Model(&models.SysMenu{}).Where("menu_id = ?", hand.MenuId).Update("paths", hand.Paths)
// Rows whose paths start with the moving one's as a string and are not
// underneath it as a path. /0/1/2 is a string prefix of /0/1/20, and a
// LIKE on the bare prefix cannot tell the two apart - so these have to
// be built against the path that actually moves, which is the one this
// repair rewrites.
var decoys []models.SysMenu
for _, suffix := range []string{"0", "1", "9"} {
d := models.SysMenu{MenuName: "Decoy" + suffix, Title: "decoy", MenuType: contractmodels.Menu}
if err := db.Create(&d).Error; err != nil {
t.Fatal(err)
}
d.Paths = list.Paths + suffix
db.Model(&models.SysMenu{}).Where("menu_id = ?", d.MenuId).Update("paths", d.Paths)
decoys = append(decoys, d)
}
if err := db.Delete(&models.SysMenu{}, "menu_id = ?", dir.MenuId).Error; err != nil {
t.Fatal(err)
}
if err := (adminSeeder{}).SeedMenus(db, "order", menus, apis); err != nil {
t.Fatalf("second seed: %v", err)
}
var newList, movedHand models.SysMenu
db.Where("app_code = ? AND seed_code = ?", "order", "list").First(&newList)
db.Where("menu_id = ?", hand.MenuId).First(&movedHand)
if want := newList.Paths + "/" + strconv.Itoa(hand.MenuId); movedHand.Paths != want {
t.Errorf("the hand-made menu's paths = %q, want %q; it no longer names its ancestors",
movedHand.Paths, want)
}
for _, d := range decoys {
var after models.SysMenu
db.Where("menu_id = ?", d.MenuId).First(&after)
if after.Paths != d.Paths {
t.Errorf("decoy %d moved from %q to %q; a prefix match caught a row that is not underneath",
d.MenuId, d.Paths, after.Paths)
}
}
}
// An application that renames a menu or moves its component in a new version
// had the change ignored: the row was found and returned untouched.
func TestSeedMenusRefreshesWhatTheSpecDecides(t *testing.T) {
db := newSeedTestDB(t)
useCompositeSeedCodeIndex(t, db)
seedAdminRole(t, db)
menus, apis := orderMenuSpecs("Orders", "apps/order/index")
if err := (adminSeeder{}).SeedMenus(db, "order", menus, apis); err != nil {
t.Fatalf("first seed: %v", err)
}
// An administrator hides it. That is not something the spec expresses,
// so a reseed has no business turning it back on.
if err := db.Model(&models.SysMenu{}).Where("app_code = ? AND seed_code = ?", "order", "list").
Update("visible", "1").Error; err != nil {
t.Fatal(err)
}
menus2, apis2 := orderMenuSpecs("Sales orders", "apps/order/list/index")
if err := (adminSeeder{}).SeedMenus(db, "order", menus2, apis2); err != nil {
t.Fatalf("second seed: %v", err)
}
var list models.SysMenu
db.Where("app_code = ? AND seed_code = ?", "order", "list").First(&list)
if list.Title != "Sales orders" {
t.Errorf("title = %q, want the new one", list.Title)
}
if list.Component != "apps/order/list/index" {
t.Errorf("component = %q, want the new one", list.Component)
}
if list.Visible != "1" {
t.Errorf("visible = %q; a reseed unhid a menu an administrator had hidden", list.Visible)
}
}
// orderMenuSpecs is a two-level tree plus one api, parameterised on the two
// columns the upgrade test changes.
func orderMenuSpecs(title, component string) ([]seed.MenuSpec, []seed.ApiSpec) {
menus := []seed.MenuSpec{
{Code: "dir", Kind: contractmodels.Directory, Title: "Order Example", Path: "/apps/order", Component: "Layout", Sort: 10},
{Code: "list", Parent: "dir", Kind: contractmodels.Menu, Title: title, Path: "list", Component: component, Sort: 1, ApiCodes: []string{"list"}},
}
apis := []seed.ApiSpec{
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"},
}
return menus, apis
}
// 1786700008000 added seed_code and left it NULL on every row already there.
// An application's rows are in that population, and the natural-key lookup
// misses them, so the seed used to insert a second copy beside each one -
// which the unique index cannot object to, because NULL never collides.
func TestSeedMenusAdoptsARowWrittenBeforeSeedCodeExisted(t *testing.T) {
db := newSeedTestDB(t)
useCompositeSeedCodeIndex(t, db)
seedAdminRole(t, db)
// What an older SeedMenus left: app_code set, seed_code absent, and the
// name that identified it then.
legacy := models.SysMenu{
MenuName: menuName("order", "dir"), AppCode: "order", Title: "the old title",
MenuType: contractmodels.Directory, Path: "/apps/order", Component: "Layout", Sort: 10,
}
if err := db.Create(&legacy).Error; err != nil {
t.Fatal(err)
}
menus, apis := orderMenuSpecs("Orders", "apps/order/index")
if err := (adminSeeder{}).SeedMenus(db, "order", menus, apis); err != nil {
t.Fatalf("seed: %v", err)
}
var rows []models.SysMenu
if err := db.Where("app_code = ? AND menu_name = ?", "order", menuName("order", "dir")).
Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 1 {
t.Fatalf("%d rows carry that name; the row from before the column existed was not found", len(rows))
}
if rows[0].MenuId != legacy.MenuId {
t.Errorf("menu_id = %d, want the row that was already there (%d)", rows[0].MenuId, legacy.MenuId)
}
if rows[0].SeedCode == nil || *rows[0].SeedCode != "dir" {
t.Errorf("seed_code = %v, want it claimed", rows[0].SeedCode)
}
// Adopted and then repaired, like any other existing row.
if rows[0].Title != "Order Example" {
t.Errorf("title = %q; the adopted row was not brought up to the spec", rows[0].Title)
}
}
// menuName concatenates two pascalCase strings and pascalCase is not
// injective, so two specs can land on one name. Picking one of several rows
// would attach an application's menu to whichever the database returned
// first.
func TestSeedMenusRefusesAnAmbiguousAdoption(t *testing.T) {
db := newSeedTestDB(t)
useCompositeSeedCodeIndex(t, db)
seedAdminRole(t, db)
for i := 0; i < 2; i++ {
row := models.SysMenu{
MenuName: menuName("order", "dir"), AppCode: "order", Title: fmt.Sprintf("copy %d", i),
MenuType: contractmodels.Directory,
}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
menus, apis := orderMenuSpecs("Orders", "apps/order/index")
err := (adminSeeder{}).SeedMenus(db, "order", menus, apis)
if err == nil {
t.Fatal("an ambiguous adoption was accepted")
}
if !strings.Contains(err.Error(), "2 rows") || !strings.Contains(err.Error(), "by hand") {
t.Errorf("error = %q, it has to say how many and that it is not deciding", err)
}
// And it did not write a third.
var n int64
db.Model(&models.SysMenu{}).Where("app_code = ? AND menu_name = ?", "order", menuName("order", "dir")).Count(&n)
if n != 2 {
t.Errorf("%d rows carry that name; the refusal still inserted", n)
}
}
// A row belonging to another application, or to the host, carries a different
// app_code and is not this application's to claim.
func TestSeedMenusDoesNotAdoptAnotherApplicationsRow(t *testing.T) {
db := newSeedTestDB(t)
useCompositeSeedCodeIndex(t, db)
seedAdminRole(t, db)
other := models.SysMenu{
MenuName: menuName("order", "dir"), AppCode: "crm", Title: "crm's own",
MenuType: contractmodels.Directory,
}
if err := db.Create(&other).Error; err != nil {
t.Fatal(err)
}
menus, apis := orderMenuSpecs("Orders", "apps/order/index")
if err := (adminSeeder{}).SeedMenus(db, "order", menus, apis); err != nil {
t.Fatalf("seed: %v", err)
}
var after models.SysMenu
db.Where("menu_id = ?", other.MenuId).First(&after)
if after.SeedCode != nil || after.Title != "crm's own" {
t.Errorf("another application's row was claimed: %+v", after)
}
var mine models.SysMenu
if err := db.Where("app_code = ? AND seed_code = ?", "order", "dir").First(&mine).Error; err != nil {
t.Fatalf("this application's own row was not created: %v", err)
}
}
+1 -1
View File
@@ -107,4 +107,4 @@ type SysRoleMenu struct {
// return nil, err
// }
// return r, nil
//}
//}
+1 -1
View File
@@ -209,7 +209,7 @@ func (e *ExecJob) addJob(c *cron.Cron) (int, error) {
// Remove 移除任务
func Remove(c *cron.Cron, entryID int) chan bool {
ch := make(chan bool)
ch := make(chan bool, 1)
go func() {
c.Remove(cron.EntryID(entryID))
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore Remove success ,info entryID :", entryID)
+1 -1
View File
@@ -39,7 +39,7 @@ func (e *SysJob) RemoveJob(c *dto.GeneralDelDto) error {
}
case <-time.After(time.Second * 1):
e.Msg = "操作超时!"
return nil
return errors.New(e.Msg)
}
return nil
}
+88
View File
@@ -0,0 +1,88 @@
package service
import (
"testing"
"time"
"github.com/glebarez/sqlite"
coreservice "github.com/go-admin-team/go-admin-core/v2/sdk/service"
"github.com/robfig/cron/v3"
"gorm.io/gorm"
"go-admin/app/jobs/models"
"go-admin/common/dto"
)
type blockedSchedule struct {
started chan struct{}
release chan struct{}
}
func (s blockedSchedule) Next(now time.Time) time.Time {
close(s.started)
<-s.release
return now.Add(time.Hour)
}
func TestRemoveJob(t *testing.T) {
for _, blocked := range []bool{false, true} {
name := "success"
if blocked {
name = "timeout"
}
t.Run(name, func(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
if err := db.AutoMigrate(&models.SysJob{}); err != nil {
t.Fatal(err)
}
c := cron.New()
schedule := blockedSchedule{make(chan struct{}), make(chan struct{})}
entryID := c.Schedule(schedule, cron.FuncJob(func() {}))
if blocked {
c.Start()
t.Cleanup(func() {
close(schedule.release)
<-c.Stop().Done()
})
select {
case <-schedule.started:
case <-time.After(5 * time.Second):
t.Fatal("scheduler did not start")
}
}
job := models.SysJob{EntryId: int(entryID)}
if err := db.Create(&job).Error; err != nil {
t.Fatal(err)
}
s := SysJob{Service: coreservice.Service{Orm: db}, Cron: c}
err = s.RemoveJob(&dto.GeneralDelDto{Id: job.JobId})
if blocked {
if err == nil || err.Error() != "操作超时!" {
t.Errorf("RemoveJob error = %v, want timeout error", err)
}
} else if err != nil {
t.Fatal(err)
}
var saved models.SysJob
if err := db.First(&saved, job.JobId).Error; err != nil {
t.Fatal(err)
}
wantEntryID := 0
if blocked {
wantEntryID = int(entryID)
}
if saved.EntryId != wantEntryID {
t.Errorf("entry_id = %d, want %d", saved.EntryId, wantEntryID)
}
})
}
}
+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)
}
}
+7 -1
View File
@@ -8,6 +8,12 @@ import (
"go-admin/app/other/models/tools"
)
// emptyTableNameMsg is what the generator's endpoints answer with when the
// request named no table. Declared once because the tests assert on it: spelled
// out again at each site, a reworded message would leave them asserting on a
// string the server no longer sends, and still passing.
const emptyTableNameMsg = "table name cannot be empty!"
// GetDBColumnList 分页列表数据
// @Summary 分页列表数据 / page list data
// @Description 数据库表列分页列表 / database table column page list
@@ -41,7 +47,7 @@ func (e Gen) GetDBColumnList(c *gin.Context) {
}
data.TableName = c.Request.FormValue("tableName")
pkg.Assert(data.TableName != "", "table name cannot be empty!", 500)
pkg.Assert(data.TableName != "", emptyTableNameMsg, 500)
result, count, err := data.GetPage(db, pageSize, pageIndex)
if err != nil {
log.Errorf("GetPage error, %s", err.Error())
+26 -10
View File
@@ -16,17 +16,21 @@ import (
"go-admin/common/middleware"
)
const emptyTableNameMsg = "table name cannot be empty!"
// bodyOf covers both the success and the CustomError shape: both carry msg.
type bodyOf struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
// newColumnListEngine wires the handler the way the router does, including the
// newEngine wires one generator handler the way the router does, including the
// middleware that turns pkg.Assert's panic into a response.
func newColumnListEngine(t *testing.T) *gin.Engine {
//
// The generator's queries target MySQL's information_schema and cannot run on
// the sqlite connection behind them; the driver setting only has to select that
// branch, since no statement here is expected to succeed. That makes this
// serviceable for any handler in this package whose behaviour is decided before
// the query goes out -- which is what these tests are about.
func newEngine(t *testing.T, method, path string, h gin.HandlerFunc) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
@@ -35,26 +39,33 @@ func newColumnListEngine(t *testing.T) *gin.Engine {
t.Fatalf("open sqlite: %v", err)
}
// The query targets MySQL's information_schema; the driver setting only has
// to select that branch, the statement itself is never expected to succeed.
previous := config.DatabaseConfig.Driver
config.DatabaseConfig.Driver = "mysql"
t.Cleanup(func() { config.DatabaseConfig.Driver = previous })
r := gin.New()
r.Use(middleware.CustomError)
r.GET("/db/columns/page", func(c *gin.Context) {
r.Handle(method, path, func(c *gin.Context) {
c.Set("db", db)
c.Set(pkg.LoggerKey, logger.NewHelper(logger.DefaultLogger))
Gen{}.GetDBColumnList(c)
h(c)
})
return r
}
func columnListMsg(t *testing.T, r *gin.Engine, query string) bodyOf {
func newColumnListEngine(t *testing.T) *gin.Engine {
t.Helper()
return newEngine(t, http.MethodGet, "/db/columns/page", Gen{}.GetDBColumnList)
}
// serveJSON runs one request through the engine and decodes the envelope every
// handler here answers with. A body that will not decode fails the test rather
// than being reported as a mismatched message, which reads as the handler
// having answered something unexpected instead of not having answered at all.
func serveJSON(t *testing.T, r *gin.Engine, req *http.Request) bodyOf {
t.Helper()
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/db/columns/page"+query, nil))
r.ServeHTTP(w, req)
var body bodyOf
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
@@ -63,6 +74,11 @@ func columnListMsg(t *testing.T, r *gin.Engine, query string) bodyOf {
return body
}
func columnListMsg(t *testing.T, r *gin.Engine, query string) bodyOf {
t.Helper()
return serveJSON(t, r, httptest.NewRequest(http.MethodGet, "/db/columns/page"+query, nil))
}
func TestGetDBColumnList_AcceptsATableName(t *testing.T) {
body := columnListMsg(t, newColumnListEngine(t), "?tableName=sys_user")
if body.Msg == emptyTableNameMsg {
+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{}
+67 -5
View File
@@ -1,12 +1,13 @@
package tools
import (
"errors"
"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"
@@ -79,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, "")
@@ -106,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, "抱歉未找到相关信息")
@@ -148,7 +149,8 @@ func (e SysTable) GetSysTablesTree(c *gin.Context) {
// @Tags 工具 / 生成工具
// @Accept application/json
// @Product application/json
// @Param tables query string false "tableName / 数据表名称"
// @Param tables query string false "tableName / 数据表名称,逗号分隔"
// @Param data body object false "tables / 同上,query 未带时从 JSON body 读"
// @Success 200 {string} string "{"code": 200, "message": "添加成功"}"
// @Success 200 {string} string "{"code": -1, "message": "添加失败"}"
// @Router /api/v1/sys/tables/info [post]
@@ -163,7 +165,13 @@ func (e SysTable) Insert(c *gin.Context) {
return
}
tablesList := strings.Split(c.Request.FormValue("tables"), ",")
tablesList, err := tablesToImport(c)
if err != nil {
log.Errorf("read the table list, %s", err.Error())
e.Error(500, err, "")
return
}
for i := 0; i < len(tablesList); i++ {
data, err := genTableInit(db, tablesList, i, c)
@@ -184,6 +192,45 @@ func (e SysTable) Insert(c *gin.Context) {
}
// tablesToImport reads the comma-separated table list carried by an import
// request, from the query string or from a JSON body.
//
// The list has only ever travelled in the query string, which is the single
// place FormValue looks once the request declares itself as JSON. A front end
// that puts it in the body instead therefore left this empty, and the import
// went on to ask information_schema for a table named "" -- go-admin-ui v3.2.0
// shipped exactly that, and every import failed with the message below.
// Reading the body when the query has nothing keeps either front end working.
func tablesToImport(c *gin.Context) ([]string, error) {
raw := c.Request.FormValue("tables")
if raw == "" {
var body struct {
Tables string `json:"tables"`
}
// A body that is absent, or shaped some other way, is not itself worth
// reporting: the list is missing either way, and the message below says
// so in the terms the caller asked in.
if err := c.ShouldBindJSON(&body); err == nil {
raw = body.Tables
}
}
parts := strings.Split(raw, ",")
names := make([]string, 0, len(parts))
for _, name := range parts {
// Splitting "" yields one empty name rather than nothing at all, so
// without this an empty list reads as a request to import one table
// whose name happens to be blank.
if name = strings.TrimSpace(name); name != "" {
names = append(names, name)
}
}
if len(names) == 0 {
return nil, errors.New(emptyTableNameMsg)
}
return names, nil
}
func genTableInit(tx *gorm.DB, tablesList []string, i int, c *gin.Context) (tools.SysTables, error) {
var data tools.SysTables
var dbTable tools.DBTables
@@ -321,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 {
+124
View File
@@ -0,0 +1,124 @@
package tools
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
// newImportRequest builds the request an import arrives in. Where the table
// list sits -- query or body -- is exactly what these tests are about, and it
// is net/http's form parsing that decides what a handler can reach, so these go
// through a real *http.Request rather than a hand-built one.
func newImportRequest(target, contentType, body string) *http.Request {
req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(body))
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
return req
}
func TestTablesToImport(t *testing.T) {
cases := []struct {
name string
target string
contentType string
body string
want []string
wantErr bool
}{{
name: "from the query, as every front end before v3.2.0 sent it",
target: "/sys/tables/info?tables=sys_user,sys_post",
want: []string{"sys_user", "sys_post"},
}, {
name: "from a JSON body, as go-admin-ui v3.2.0 sends it",
target: "/sys/tables/info",
contentType: "application/json",
body: `{"tables":"sys_user,sys_post"}`,
want: []string{"sys_user", "sys_post"},
}, {
name: "the query wins when a request carries both",
target: "/sys/tables/info?tables=sys_user",
contentType: "application/json",
body: `{"tables":"sys_post"}`,
want: []string{"sys_user"},
}, {
name: "blank entries are dropped rather than imported as a nameless table",
target: "/sys/tables/info?tables=sys_user,,%20,sys_post",
want: []string{"sys_user", "sys_post"},
}, {
name: "a body carrying an empty list is an error",
target: "/sys/tables/info",
contentType: "application/json",
body: `{"tables":""}`,
wantErr: true,
}, {
name: "a body that is not JSON at all is an error, not a panic",
target: "/sys/tables/info",
contentType: "application/json",
body: "sys_user",
wantErr: true,
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = newImportRequest(tc.target, tc.contentType, tc.body)
got, err := tablesToImport(c)
if tc.wantErr {
if err == nil {
t.Fatalf("expected an error, got %q", got)
}
if err.Error() != emptyTableNameMsg {
t.Fatalf("message should be the one the front end shows, got %q", err.Error())
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
t.Fatalf("got %q, want %q", got, tc.want)
}
})
}
}
// insertMsg runs one import through the wired handler. It asserts nothing about
// the import succeeding -- it cannot, over sqlite -- only about how far the
// request got, which the empty-list message is what distinguishes.
func insertMsg(t *testing.T, target, contentType, body string) bodyOf {
t.Helper()
return serveJSON(t,
newEngine(t, http.MethodPost, "/sys/tables/info", SysTable{}.Insert),
newImportRequest(target, contentType, body))
}
func TestInsert_ReadsTheTableListFromEitherPlace(t *testing.T) {
for _, tc := range []struct {
name string
target string
contentType string
body string
}{
{"query", "/sys/tables/info?tables=sys_user", "", ""},
{"JSON body", "/sys/tables/info", "application/json", `{"tables":"sys_user"}`},
} {
t.Run(tc.name, func(t *testing.T) {
if got := insertMsg(t, tc.target, tc.contentType, tc.body); got.Msg == emptyTableNameMsg {
t.Fatalf("request carried a table name and was still rejected as empty: %+v", got)
}
})
}
}
func TestInsert_RejectsAMissingTableList(t *testing.T) {
if got := insertMsg(t, "/sys/tables/info", "", ""); got.Msg != emptyTableNameMsg {
t.Fatalf("missing table list should be rejected, got %+v", got)
}
}
+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)
+45
View File
@@ -0,0 +1,45 @@
package migrate
import (
"bytes"
"errors"
"strings"
"testing"
)
// A deployment decides whether to start the new version on this command's
// exit code. Before this batch the only failure that produced one was a
// failing migration function, and it produced it by ending the process from
// inside the migration engine; moving that out would have taken the last
// reported failure with it.
func TestExitOnErrorEndsTheCommandNonZero(t *testing.T) {
var codes []int
osExit = func(c int) { codes = append(codes, c) }
t.Cleanup(func() { osExit = origExit })
var out bytes.Buffer
exitOnError(&out, errors.New("the tenant database is unreachable"))
if len(codes) != 1 || codes[0] != 1 {
t.Errorf("exit codes = %v, want [1]", codes)
}
if !strings.Contains(out.String(), "the tenant database is unreachable") {
t.Errorf("the reason was not reported: %q", out.String())
}
}
func TestExitOnErrorLetsSuccessThrough(t *testing.T) {
var codes []int
osExit = func(c int) { codes = append(codes, c) }
t.Cleanup(func() { osExit = origExit })
var out bytes.Buffer
exitOnError(&out, nil)
if len(codes) != 0 {
t.Errorf("a successful migration exited with %v", codes)
}
if out.Len() != 0 {
t.Errorf("a successful migration wrote %q", out.String())
}
}
+472
View File
@@ -0,0 +1,472 @@
package migrate
import (
"errors"
"fmt"
"io"
"slices"
"sort"
"strings"
"time"
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/app"
"gorm.io/gorm"
adminmodels "go-admin/app/admin/models"
"go-admin/cmd/migrate/migration"
)
// engine is the part of the migration engine the installer drives.
//
// An interface rather than *migration.Migration because the concrete type is
// a package-level singleton with no exported constructor, so a test that took
// it would be sharing one registry with every other test in the process.
type engine interface {
SetDb(*gorm.DB)
Status() ([]migration.StatusEntry, error)
MigrateApp(string) error
}
// installReport is what an install did, for the command to print.
type installReport struct {
Code string
// Version is the manifest version this run recorded.
Version string
// Previous is the version sys_app held before this run, empty when this
// is the first install.
Previous string
// Applied lists the versions this run brought in, in the order they were
// applied. Empty on a no-op, and also empty on a run that only corrected
// sys_app - the difference is NoOp.
Applied []string
// NoOp says nothing was left to do: the app is recorded as installed, at
// this same version, with no migration outstanding.
NoOp bool
}
// install brings one application up to the version its manifest declares.
//
// Three phases, each committing on its own. They are not one transaction and
// cannot be: an application's versions are separate migration files, and a
// DDL statement inside any of them commits the transaction around it on
// MySQL, which destroys an outer transaction and every savepoint taken from
// it. So this does not
// promise that a half-installed application cannot happen. It promises that
// one is visible when it does: phase A writes "installing" before anything
// that can fail, and phase C turns that into "installed" or "failed".
//
// What is left to apply comes from sys_migration, never from sys_app.
// sys_app is a derived view - a summary for a human, and the answer to "which
// version does this app think it is at". If it were the authority, then an
// operator who deleted sys_migration rows by hand would be told an app is
// installed while its schema is not, which is worse than not knowing.
func install(db *gorm.DB, eng engine, m app.Manifest) (installReport, error) {
code := migration.NormalizeAppCode(m.Code)
rep := installReport{Code: code, Version: m.Version}
if code == "" {
return rep, errors.New("the manifest declares no app code")
}
if code == migration.FrameworkAppCode {
// Installing the framework is what `migrate` is, and the framework
// has no manifest and no sys_app row. Saying so beats writing a row
// that nothing else in this batch expects to exist.
return rep, fmt.Errorf("%q is the framework's own migrations, not an application; run `migrate` for those", code)
}
if !db.Migrator().HasTable(&adminmodels.SysApp{}) {
return rep, errors.New("sys_app does not exist; run `migrate` first to bring the framework's own tables up to date")
}
eng.SetDb(db)
row, found, err := loadApp(db, code)
if err != nil {
return rep, err
}
// sameVersion is only meaningful when found; it stays false otherwise.
// The comparison happens here, before phase A, so an unparseable
// recorded version is refused while it is still readable rather than
// after being overwritten.
sameVersion := false
if found {
rep.Previous = row.Version
cmp, err := app.Compare(m.Version, row.Version)
if err != nil {
return rep, fmt.Errorf("comparing %s against the recorded %s: %w", m.Version, row.Version, err)
}
if cmp < 0 {
return rep, fmt.Errorf("%s is recorded at %s; installing %s would be a downgrade, which is not supported",
code, row.Version, m.Version)
}
sameVersion = cmp == 0
}
if err := requiresInstalled(db, code, m); err != nil {
return rep, err
}
pending, err := pendingFor(eng, code)
if err != nil {
return rep, err
}
// Nothing outstanding, recorded as installed, at this same version. All
// three, and the first one comes from sys_migration: a row that says
// installed while a migration of its has never run is exactly the case
// sys_app must not be believed about. AppInstalling is not installed -
// it is what a row reads as after the process was killed partway.
if found && sameVersion && row.Status == adminmodels.AppInstalled && len(pending) == 0 {
rep.NoOp = true
return rep, nil
}
// Phase A: the attempt is on disk before anything that can fail.
now := time.Now()
if err := beginInstall(db, &row, m, code, found, now); err != nil {
return rep, err
}
// Phase B: no atomicity across these, by the nature of the thing.
runErr := eng.MigrateApp(code)
// Phase C.
if runErr != nil {
failed := ""
var vf *migration.VersionFailure
if errors.As(runErr, &vf) {
failed = vf.Version
}
if err := markFailed(db, code, failed, runErr, time.Now()); err != nil {
return rep, errors.Join(runErr, fmt.Errorf("recording the failure on sys_app: %w", err))
}
return rep, runErr
}
if err := markInstalled(db, code, row.InstalledAt, time.Now()); err != nil {
return rep, err
}
rep.Applied = pending
return rep, nil
}
// loadApp reads the sys_app row for code. A missing row is not an error: it
// is what a first install looks like.
func loadApp(db *gorm.DB, code string) (adminmodels.SysApp, bool, error) {
var row adminmodels.SysApp
err := db.Where("app_code = ?", code).First(&row).Error
if err == nil {
return row, true, nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return adminmodels.SysApp{}, false, nil
}
return adminmodels.SysApp{}, false, fmt.Errorf("reading sys_app for %q: %w", code, err)
}
// requiresInstalled refuses an install whose declared dependencies are not
// installed, and names the ones that are not.
//
// It does not install them. "Install this application" would otherwise mean
// "and everything it happens to name, and everything those name" - a blast
// radius the operator did not ask for and cannot see before it happens. What
// they get instead is a list and the order to do it in.
//
// An unfinished or failed dependency counts as missing, and says which it is:
// "not installed" sends someone to install it, "did not finish" sends them to
// look at why.
func requiresInstalled(db *gorm.DB, code string, m app.Manifest) error {
if len(m.Requires) == 0 {
return nil
}
apps, err := loadApps(db)
if err != nil {
return err
}
var why, what []string
for _, req := range m.Requires {
want := migration.NormalizeAppCode(req)
if want == "" {
continue
}
var reason string
switch row, ok := apps[want]; {
case !ok:
reason = "not installed"
case row.Status == adminmodels.AppFailed:
reason = "its install failed"
case row.Status == adminmodels.AppInstalling:
reason = "its install did not finish"
case row.Status != adminmodels.AppInstalled:
// A status this binary has no name for. Saying so beats the
// catch-all this used to be, which read any future value as
// "did not finish" - a sentence that would be wrong for
// whatever reason the value was added.
reason = fmt.Sprintf("its status is %d, which this binary does not recognise", row.Status)
default:
continue
}
why = append(why, want+" ("+reason+")")
what = append(what, want)
}
if len(why) > 0 {
return fmt.Errorf("%s requires %s; install %s first",
code, strings.Join(why, ", "), strings.Join(what, " and "))
}
return nil
}
// refuseOnDependencyCycle reports a cycle anywhere in the registered
// manifests, whether or not the application being installed is part of it.
//
// Over the whole set rather than one application's closure, because a cycle
// between two applications neither of which is the one being installed is
// still an authoring mistake, and finding it the day somebody happens to
// install into it - with an error naming two applications they did not ask
// for - is the worse time to find it.
//
// Requires naming an application that is not registered is not a cycle and
// not reported here; that is requiresInstalled's answer to give, against the
// database, at the time it matters.
func refuseOnDependencyCycle(manifests map[string]app.Manifest) error {
const (
white = 0 // not visited
grey = 1 // on the current path
black = 2 // finished
)
colour := make(map[string]int, len(manifests))
codes := make([]string, 0, len(manifests))
for code := range manifests {
codes = append(codes, code)
}
// Sorted, so the same set of manifests always reports the same cycle
// rather than whichever one the map happened to hand over first.
sort.Strings(codes)
var path []string
var walk func(code string) error
walk = func(code string) error {
switch colour[code] {
case grey:
// Trim the path to where this code first appears, so the error
// is the cycle and not the walk that reached it. grey is only
// ever set together with the append below, and cleared together
// with the matching trim, so the code is always on the path.
cycle := append(slices.Clone(path[slices.Index(path, code):]), code)
return fmt.Errorf("the declared dependencies form a cycle: %s",
strings.Join(cycle, " -> "))
case black:
return nil
}
colour[code] = grey
path = append(path, code)
// In the order the manifest declared them, which is a fixed order
// already - sorting here would only make the reported cycle harder
// to line up against the manifest that caused it. The determinism
// that matters comes from the sorted outer loop, because that one
// walks a map.
for _, r := range manifests[code].Requires {
n := migration.NormalizeAppCode(r)
if _, registered := manifests[n]; !registered {
// Including the empty string, which Register rejects, so
// no manifest is filed under it.
continue
}
if err := walk(n); err != nil {
return err
}
}
path = path[:len(path)-1]
colour[code] = black
return nil
}
for _, code := range codes {
if err := walk(code); err != nil {
return err
}
}
return nil
}
// loadApps reads every sys_app row, keyed by app code.
//
// A database that has never had 1786700007000 applied has no such table, and
// that is not an error here: `migrate status` has to keep working on a
// database that has not been migrated at all, which is when it is most wanted.
// A nil map is the honest answer there, and the caller prints what it always
// printed.
func loadApps(db *gorm.DB) (map[string]adminmodels.SysApp, error) {
if !db.Migrator().HasTable(&adminmodels.SysApp{}) {
return nil, nil
}
var rows []adminmodels.SysApp
if err := db.Find(&rows).Error; err != nil {
return nil, fmt.Errorf("reading sys_app: %w", err)
}
out := make(map[string]adminmodels.SysApp, len(rows))
for _, r := range rows {
// An application cannot be filed under the empty code or the one
// reserved for the framework - Register rejects both - so a row
// carrying either was not written by an install. Dropping it here
// is the one place that settles it: every reader of this map would
// otherwise have to decide separately, and `migrate status` would
// merge such a row into the framework's own group.
if r.AppCode == "" || r.AppCode == migration.FrameworkAppCode {
continue
}
out[r.AppCode] = r
}
return out, nil
}
// pendingFor is the authoritative answer to "what is left to apply", and it
// is recomputed every time rather than stored: what is registered in this
// process, minus what sys_migration says has run. sys_app.failed_version is a
// snapshot of what this returned once and may be stale by now; nothing may
// read it to decide this.
func pendingFor(eng engine, code string) ([]string, error) {
entries, err := eng.Status()
if err != nil {
return nil, err
}
var out []string
for _, e := range entries {
if e.AppCode == code && e.Registered && !e.Applied {
out = append(out, e.Version)
}
}
sort.Strings(out)
return out, nil
}
// beginInstall is phase A. It refreshes every descriptive column from the
// manifest, because those are the manifest's to say and the row is only a
// copy, and it clears the two diagnostic columns so a stale failure from a
// previous attempt cannot be read as this one's.
func beginInstall(db *gorm.DB, row *adminmodels.SysApp, m app.Manifest, code string, found bool, now time.Time) error {
row.AppCode = code
row.Name = m.Name
row.Version = m.Version
row.Description = m.Description
row.Author = m.Author
row.Requires = strings.Join(m.Requires, ",")
row.Pricing = m.Pricing
row.License = m.License
row.Status = adminmodels.AppInstalling
row.FailedVersion = ""
row.LastError = ""
row.UpdatedAt = now
if !found {
if err := db.Create(row).Error; err != nil {
return fmt.Errorf("recording the install attempt for %q: %w", code, err)
}
return nil
}
if err := db.Save(row).Error; err != nil {
return fmt.Errorf("recording the install attempt for %q: %w", code, err)
}
return nil
}
// markInstalled is the success half of phase C. installed_at is set once and
// never moved: an upgrade keeps the time of the first install, which is what
// the column is for.
//
// Computed here rather than with COALESCE so the statement is the same on all
// four drivers this repository supports.
func markInstalled(db *gorm.DB, code string, installedAt *time.Time, now time.Time) error {
updates := map[string]any{
"status": adminmodels.AppInstalled,
"updated_at": now,
}
if installedAt == nil {
updates["installed_at"] = now
}
err := db.Model(&adminmodels.SysApp{}).Where("app_code = ?", code).Updates(updates).Error
if err != nil {
return fmt.Errorf("recording %q as installed: %w", code, err)
}
return nil
}
// markFailed is the other half. Both columns it writes are diagnostic text
// for whoever reads the row; no code may branch on either one.
func markFailed(db *gorm.DB, code, failedVersion string, cause error, now time.Time) error {
updates := map[string]any{
"status": adminmodels.AppFailed,
"failed_version": truncate(failedVersion, 64),
"last_error": truncate(cause.Error(), 255),
"updated_at": now,
}
return db.Model(&adminmodels.SysApp{}).Where("app_code = ?", code).Updates(updates).Error
}
// truncate cuts s to at most n runes, not bytes: these columns are declared in
// characters, and a message that is partly Chinese would otherwise be cut in
// the middle of one and stored as an invalid sequence.
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}
// reportInstall prints what happened, and says that the data is in place but
// the code is not.
//
// That last sentence is not a pleasantry. Go links its applications at build
// time and Vite resolves its import globs at build time, so installing an
// application writes its menus, its APIs and its permissions and cannot make
// one line of its code run. An operator who is not told that sees the menus
// appear and reasonably concludes the thing is live.
func reportInstall(w io.Writer, rep installReport) {
if rep.NoOp {
fmt.Fprintf(w, "%s %s is already installed; nothing to do\n", rep.Code, rep.Version)
return
}
switch {
case rep.Previous == "":
fmt.Fprintf(w, "installed %s %s\n", rep.Code, rep.Version)
case rep.Previous == rep.Version:
fmt.Fprintf(w, "brought %s %s the rest of the way\n", rep.Code, rep.Version)
default:
fmt.Fprintf(w, "upgraded %s from %s to %s\n", rep.Code, rep.Previous, rep.Version)
}
if len(rep.Applied) > 0 {
fmt.Fprintf(w, "applied %d migration(s): %s\n", len(rep.Applied), strings.Join(rep.Applied, ", "))
} else {
fmt.Fprintln(w, "no migration was outstanding; only sys_app was brought up to date")
}
fmt.Fprintln(w, "the database is up to date, but the application's code is not running yet:")
fmt.Fprintln(w, "rebuild and restart the server before expecting its routes to answer.")
}
// manifestFor finds the manifest an application registered for code.
//
// A code nothing registered is an error naming what is registered, for the
// same reason exitUnlessAppRegistered exists: the alternative is telling an
// operator who typed `install ordr` that there was nothing to do.
// Takes the snapshot rather than reading it, so this lookup and the caller's
// cycle check see the same set. Two calls to app.Snapshot() would also be two
// deep copies of the registry for one install.
func manifestFor(all map[string]app.Manifest, code string) (app.Manifest, error) {
want := migration.NormalizeAppCode(code)
if m, ok := all[want]; ok {
return m, nil
}
codes := make([]string, 0, len(all))
for c := range all {
codes = append(codes, c)
}
sort.Strings(codes)
if len(codes) == 0 {
// Worth its own sentence: no application is compiled into this
// binary at all, which is a different thing from having typed the
// wrong one of several.
return app.Manifest{}, fmt.Errorf(
"no application registers a manifest in this binary, so %q cannot be installed; "+
"an application has to be compiled in before it can be installed", want)
}
return app.Manifest{}, fmt.Errorf("no application registers the code %q; registered: %s",
want, strings.Join(codes, ", "))
}
+649
View File
@@ -0,0 +1,649 @@
package migrate
import (
"errors"
"strings"
"testing"
"github.com/glebarez/sqlite"
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/app"
"gorm.io/gorm"
"gorm.io/gorm/logger"
adminmodels "go-admin/app/admin/models"
"go-admin/cmd/migrate/migration"
)
func newInstallDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&adminmodels.SysApp{}); err != nil {
t.Fatalf("automigrate sys_app: %v", err)
}
return db
}
// fakeEngine stands in for the migration engine. The real one is a
// package-level singleton with no exported constructor, so a test taking it
// would share one registry with every other test in this process.
type fakeEngine struct {
entries []migration.StatusEntry
// failWith, when set, is what MigrateApp returns instead of applying.
failWith error
calls []string
}
func (f *fakeEngine) SetDb(*gorm.DB) {}
func (f *fakeEngine) Status() ([]migration.StatusEntry, error) {
out := make([]migration.StatusEntry, len(f.entries))
copy(out, f.entries)
return out, nil
}
func (f *fakeEngine) MigrateApp(code string) error {
f.calls = append(f.calls, code)
if f.failWith != nil {
return f.failWith
}
for i := range f.entries {
if f.entries[i].AppCode == code && f.entries[i].Registered {
f.entries[i].Applied = true
}
}
return nil
}
func orderManifest(version string) app.Manifest {
return app.Manifest{
Code: "order",
Name: "Orders",
Version: version,
Description: "order management",
Author: "go-admin",
// No dependency by default: these tests are about installing, and a
// declared requirement would make every one of them set up a second
// application first. requiresInstalled has its own tests below.
Requires: nil,
Pricing: "free",
License: "MIT",
}
}
// appRow writes one sys_app row: what another application looks like to the
// installer, in whichever state the caller is testing against.
func appRow(t *testing.T, db *gorm.DB, code string, status int) {
t.Helper()
if err := db.Create(&adminmodels.SysApp{
AppCode: code, Name: code, Version: "1.0.0", Status: status,
}).Error; err != nil {
t.Fatalf("seeding %q with status %d: %v", code, status, err)
}
}
func loadRow(t *testing.T, db *gorm.DB, code string) adminmodels.SysApp {
t.Helper()
var row adminmodels.SysApp
if err := db.Where("app_code = ?", code).First(&row).Error; err != nil {
t.Fatalf("sys_app has no row for %q: %v", code, err)
}
return row
}
// A1: a first install records the app, at the version the manifest declares,
// with every descriptive column copied from it.
func TestInstallRecordsAFirstInstall(t *testing.T) {
db := newInstallDB(t)
eng := &fakeEngine{entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true},
{Version: "order-1786800002000", AppCode: "order", Registered: true},
{Version: "crm-1786800001000", AppCode: "crm", Registered: true},
}}
rep, err := install(db, eng, orderManifest("1.0.0"))
if err != nil {
t.Fatalf("install: %v", err)
}
if rep.NoOp {
t.Error("a first install reported nothing to do")
}
if got, want := len(rep.Applied), 2; got != want {
t.Errorf("applied %v, want %d versions", rep.Applied, want)
}
// Only this app's migrations, not every pending one in the process.
if len(eng.calls) != 1 || eng.calls[0] != "order" {
t.Errorf("MigrateApp calls = %v", eng.calls)
}
row := loadRow(t, db, "order")
if row.Status != adminmodels.AppInstalled {
t.Errorf("status = %d, want installed", row.Status)
}
if row.Version != "1.0.0" {
t.Errorf("version = %q", row.Version)
}
if row.InstalledAt == nil {
t.Error("installed_at was not set")
}
if row.Name != "Orders" || row.Author != "go-admin" || row.Description != "order management" {
t.Errorf("descriptive columns not copied from the manifest: %+v", row)
}
if row.Pricing != "free" || row.License != "MIT" {
t.Errorf("the reserved fields were not carried through: %+v", row)
}
}
// A2: installing the same version again is a no-op, and says so.
func TestInstallIsANoOpAtTheSameVersion(t *testing.T) {
db := newInstallDB(t)
eng := &fakeEngine{entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true},
}}
if _, err := install(db, eng, orderManifest("1.0.0")); err != nil {
t.Fatalf("first install: %v", err)
}
before := loadRow(t, db, "order")
rep, err := install(db, eng, orderManifest("1.0.0"))
if err != nil {
t.Fatalf("second install: %v", err)
}
if !rep.NoOp {
t.Error("installing the same version again was not reported as a no-op")
}
if len(eng.calls) != 1 {
t.Errorf("the engine was driven again: %v", eng.calls)
}
after := loadRow(t, db, "order")
if !after.UpdatedAt.Equal(before.UpdatedAt) {
t.Error("a no-op rewrote the row")
}
var n int64
db.Model(&adminmodels.SysApp{}).Count(&n)
if n != 1 {
t.Errorf("sys_app has %d rows, want 1", n)
}
}
// A no-op is only a no-op when nothing is outstanding. A row that says
// installed while a migration of its has never run is the case sys_app must
// not be believed over sys_migration.
func TestInstallRunsWhenTheRowSaysInstalledButAMigrationIsPending(t *testing.T) {
db := newInstallDB(t)
eng := &fakeEngine{entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true, Applied: true},
}}
if _, err := install(db, eng, orderManifest("1.0.0")); err != nil {
t.Fatalf("first install: %v", err)
}
// A second version of the same app appears - the app was rebuilt with
// one more migration file, without its version changing.
eng.entries = append(eng.entries, migration.StatusEntry{
Version: "order-1786800002000", AppCode: "order", Registered: true,
})
rep, err := install(db, eng, orderManifest("1.0.0"))
if err != nil {
t.Fatalf("install: %v", err)
}
if rep.NoOp {
t.Fatal("an outstanding migration was reported as nothing to do")
}
if len(rep.Applied) != 1 || rep.Applied[0] != "order-1786800002000" {
t.Errorf("applied = %v", rep.Applied)
}
}
// A9: an upgrade is in place. installed_at is the first install's, not this
// one's.
func TestInstallUpgradesInPlaceAndKeepsTheFirstInstallTime(t *testing.T) {
db := newInstallDB(t)
eng := &fakeEngine{entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true},
}}
if _, err := install(db, eng, orderManifest("1.0.0")); err != nil {
t.Fatalf("first install: %v", err)
}
first := loadRow(t, db, "order")
if first.InstalledAt == nil {
t.Fatal("installed_at was not set by the first install")
}
eng.entries = append(eng.entries, migration.StatusEntry{
Version: "order-1786800002000", AppCode: "order", Registered: true,
})
rep, err := install(db, eng, orderManifest("2.0.0"))
if err != nil {
t.Fatalf("upgrade: %v", err)
}
if rep.Previous != "1.0.0" {
t.Errorf("previous = %q, want 1.0.0", rep.Previous)
}
row := loadRow(t, db, "order")
if row.Version != "2.0.0" {
t.Errorf("version = %q, want 2.0.0", row.Version)
}
if row.Status != adminmodels.AppInstalled {
t.Errorf("status = %d, want installed", row.Status)
}
if !row.InstalledAt.Equal(*first.InstalledAt) {
t.Errorf("installed_at moved from %v to %v; an upgrade keeps the first install's time",
first.InstalledAt, row.InstalledAt)
}
}
// A10: a downgrade is refused, and refused before anything is written.
func TestInstallRefusesADowngrade(t *testing.T) {
db := newInstallDB(t)
eng := &fakeEngine{entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true},
}}
if _, err := install(db, eng, orderManifest("2.0.0")); err != nil {
t.Fatalf("first install: %v", err)
}
before := loadRow(t, db, "order")
_, err := install(db, eng, orderManifest("1.0.0"))
if err == nil {
t.Fatal("a downgrade was accepted")
}
if !strings.Contains(err.Error(), "downgrade") {
t.Errorf("error = %q, it has to say what it refused", err)
}
after := loadRow(t, db, "order")
if after.Version != before.Version || after.Status != before.Status {
t.Errorf("the refused downgrade still wrote to the row: %+v -> %+v", before, after)
}
}
// A5: a failing migration leaves a row that says so, and says where.
func TestInstallRecordsAFailure(t *testing.T) {
db := newInstallDB(t)
boom := errors.New("the seed hit a duplicate")
eng := &fakeEngine{
entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true},
},
failWith: &migration.VersionFailure{Version: "order-1786800001000", Err: boom},
}
_, err := install(db, eng, orderManifest("1.0.0"))
if err == nil {
t.Fatal("a failed install reported success")
}
if !errors.Is(err, boom) {
t.Errorf("the cause is not reachable: %v", err)
}
row := loadRow(t, db, "order")
if row.Status != adminmodels.AppFailed {
t.Errorf("status = %d, want failed", row.Status)
}
if row.FailedVersion != "order-1786800001000" {
t.Errorf("failed_version = %q", row.FailedVersion)
}
if !strings.Contains(row.LastError, "duplicate") {
t.Errorf("last_error = %q", row.LastError)
}
if row.InstalledAt != nil {
t.Error("installed_at was set by an install that failed")
}
}
// A failed install is retried by running it again - not by any special
// command, and without the previous attempt's diagnostics surviving into a
// row that now says installed.
func TestInstallResumesAfterAFailure(t *testing.T) {
db := newInstallDB(t)
eng := &fakeEngine{
entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true},
},
failWith: &migration.VersionFailure{Version: "order-1786800001000", Err: errors.New("boom")},
}
if _, err := install(db, eng, orderManifest("1.0.0")); err == nil {
t.Fatal("the first attempt did not fail")
}
eng.failWith = nil
rep, err := install(db, eng, orderManifest("1.0.0"))
if err != nil {
t.Fatalf("retry: %v", err)
}
if rep.NoOp {
t.Error("a failed row was treated as installed")
}
row := loadRow(t, db, "order")
if row.Status != adminmodels.AppInstalled {
t.Errorf("status = %d, want installed", row.Status)
}
if row.FailedVersion != "" || row.LastError != "" {
t.Errorf("the previous failure survived onto a row that now says installed: %q / %q",
row.FailedVersion, row.LastError)
}
if row.InstalledAt == nil {
t.Error("installed_at was not set by the attempt that succeeded")
}
}
// A row stuck at installing - the process was killed partway - is not
// installed, and must not be mistaken for it.
func TestInstallRetriesARowStuckAtInstalling(t *testing.T) {
db := newInstallDB(t)
if err := db.Create(&adminmodels.SysApp{
AppCode: "order", Name: "Orders", Version: "1.0.0",
Status: adminmodels.AppInstalling,
}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
eng := &fakeEngine{entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true, Applied: true},
}}
rep, err := install(db, eng, orderManifest("1.0.0"))
if err != nil {
t.Fatalf("install: %v", err)
}
if rep.NoOp {
t.Fatal("a row stuck at installing was reported as already installed")
}
if row := loadRow(t, db, "order"); row.Status != adminmodels.AppInstalled {
t.Errorf("status = %d, want installed", row.Status)
}
}
func TestInstallRejectsTheFrameworkCode(t *testing.T) {
db := newInstallDB(t)
m := orderManifest("1.0.0")
m.Code = migration.FrameworkAppCode
_, err := install(db, &fakeEngine{}, m)
if err == nil {
t.Fatal("the framework was installed as an application")
}
if !strings.Contains(err.Error(), "migrate") {
t.Errorf("error = %q, it should point at the command that does this", err)
}
}
func TestInstallRefusesAnUnparseableRecordedVersion(t *testing.T) {
db := newInstallDB(t)
if err := db.Create(&adminmodels.SysApp{
AppCode: "order", Name: "Orders", Version: "v1.0", Status: adminmodels.AppInstalled,
}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
_, err := install(db, &fakeEngine{}, orderManifest("1.0.0"))
if err == nil {
t.Fatal("an unparseable recorded version was compared anyway")
}
row := loadRow(t, db, "order")
if row.Status != adminmodels.AppInstalled || row.Version != "v1.0" {
t.Errorf("the row was overwritten before the comparison failed: %+v", row)
}
}
// A7: the report has to say the code is not running yet. Menus appearing is
// exactly what makes an operator think it is.
func TestReportInstallSaysTheCodeIsNotRunningYet(t *testing.T) {
var out strings.Builder
reportInstall(&out, installReport{Code: "order", Version: "1.0.0", Applied: []string{"order-1786800001000"}})
got := out.String()
if !strings.Contains(got, "rebuild") || !strings.Contains(got, "restart") {
t.Errorf("the report does not say the binary has to be rebuilt: %q", got)
}
if !strings.Contains(got, "order-1786800001000") {
t.Errorf("the report does not name what it applied: %q", got)
}
}
func TestReportInstallOnANoOp(t *testing.T) {
var out strings.Builder
reportInstall(&out, installReport{Code: "order", Version: "1.0.0", NoOp: true})
if !strings.Contains(out.String(), "already installed") {
t.Errorf("output = %q", out.String())
}
}
// last_error is a varchar(255) declared in characters. A message that is
// partly Chinese would be cut mid-rune by a byte-wise truncation and stored
// as an invalid sequence.
func TestTruncateCutsRunesNotBytes(t *testing.T) {
s := strings.Repeat("迁", 300)
got := truncate(s, 255)
if n := len([]rune(got)); n != 255 {
t.Errorf("kept %d runes, want 255", n)
}
if !strings.HasPrefix(s, got) {
t.Error("truncation did not cut at a rune boundary")
}
if short := truncate("ok", 255); short != "ok" {
t.Errorf("a short message was altered: %q", short)
}
}
func TestInstallNeedsSysApp(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
_, err = install(db, &fakeEngine{}, orderManifest("1.0.0"))
if err == nil {
t.Fatal("install ran against a database with no sys_app")
}
if !strings.Contains(err.Error(), "migrate") {
t.Errorf("error = %q, it should say what to run first", err)
}
}
// The code written to sys_app and handed to the engine is the normalized one.
// A manifest whose Code was typed with different case or stray spaces has to
// land on the same identity migration.ForApp and seed.SeedMenus already use,
// or the row and the migrations it stands for are filed under two names.
func TestInstallNormalizesTheAppCode(t *testing.T) {
db := newInstallDB(t)
eng := &fakeEngine{entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true},
}}
m := orderManifest("1.0.0")
m.Code = " Order "
rep, err := install(db, eng, m)
if err != nil {
t.Fatalf("install: %v", err)
}
if rep.Code != "order" {
t.Errorf("reported code = %q, want order", rep.Code)
}
if len(eng.calls) != 1 || eng.calls[0] != "order" {
t.Errorf("the engine was asked for %v, want [order]", eng.calls)
}
// The row has to be findable by the normalized code, which is what every
// other table in this batch is keyed by.
row := loadRow(t, db, "order")
if row.AppCode != "order" {
t.Errorf("app_code = %q", row.AppCode)
}
if len(rep.Applied) != 1 {
t.Errorf("applied = %v; the normalized code has to match what Status reports", rep.Applied)
}
}
// The manifest's dependency list is stored as it was declared, in the CSV
// shape sys_app.requires carries.
func TestInstallStoresTheDeclaredRequires(t *testing.T) {
db := newInstallDB(t)
appRow(t, db, "crm", adminmodels.AppInstalled)
appRow(t, db, "billing", adminmodels.AppInstalled)
eng := &fakeEngine{entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true},
}}
m := orderManifest("1.0.0")
m.Requires = []string{"crm", "billing"}
if _, err := install(db, eng, m); err != nil {
t.Fatalf("install: %v", err)
}
if row := loadRow(t, db, "order"); row.Requires != "crm,billing" {
t.Errorf("requires = %q, want the manifest's list as CSV", row.Requires)
}
}
// An application is not installed for you because something else names it.
// "Install this" would otherwise mean "and everything it happens to name, and
// everything those name".
func TestInstallRefusesWhenADependencyIsNotInstalled(t *testing.T) {
db := newInstallDB(t)
eng := &fakeEngine{entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true},
}}
m := orderManifest("1.0.0")
m.Requires = []string{"crm"}
_, err := install(db, eng, m)
if err == nil {
t.Fatal("an application with an uninstalled dependency was installed")
}
if !strings.Contains(err.Error(), "crm") || !strings.Contains(err.Error(), "not installed") {
t.Errorf("error = %q, it has to name what is missing and why", err)
}
if len(eng.calls) != 0 {
t.Errorf("the engine ran anyway: %v", eng.calls)
}
// Refused before phase A, so a refusal leaves nothing behind.
if n := count(t, db, "sys_app", "app_code = ?", "order"); n != 0 {
t.Errorf("a refused install wrote %d sys_app row(s)", n)
}
}
// A dependency whose own install failed or never finished is not a dependency
// that is there, and the two say which they are - one sends you to install it,
// the other to look at why.
func TestInstallRefusesWhenADependencyIsNotFinished(t *testing.T) {
for _, tc := range []struct {
name string
status int
want string
}{
{"failed", adminmodels.AppFailed, "its install failed"},
{"installing", adminmodels.AppInstalling, "did not finish"},
} {
t.Run(tc.name, func(t *testing.T) {
db := newInstallDB(t)
appRow(t, db, "crm", tc.status)
m := orderManifest("1.0.0")
m.Requires = []string{"crm"}
_, err := install(db, &fakeEngine{}, m)
if err == nil {
t.Fatal("the dependency was accepted")
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("error = %q, want it to say %q", err, tc.want)
}
})
}
}
func TestInstallAcceptsASatisfiedDependency(t *testing.T) {
db := newInstallDB(t)
appRow(t, db, "crm", adminmodels.AppInstalled)
eng := &fakeEngine{entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true},
}}
m := orderManifest("1.0.0")
m.Requires = []string{"crm"}
if _, err := install(db, eng, m); err != nil {
t.Fatalf("install: %v", err)
}
if row := loadRow(t, db, "order"); row.Status != adminmodels.AppInstalled {
t.Errorf("status = %d, want installed", row.Status)
}
}
func TestDependencyCycleIsRefused(t *testing.T) {
manifests := map[string]app.Manifest{
"a": {Code: "a", Requires: []string{"b"}},
"b": {Code: "b", Requires: []string{"c"}},
"c": {Code: "c", Requires: []string{"a"}},
}
err := refuseOnDependencyCycle(manifests)
if err == nil {
t.Fatal("a cycle was accepted")
}
// The error is the cycle, not the walk that reached it.
if !strings.Contains(err.Error(), "a -> b -> c -> a") {
t.Errorf("error = %q", err)
}
}
// A cycle between two applications neither of which is being installed is
// still an authoring mistake, and the day somebody installs into it is the
// worse time to find out.
func TestDependencyCycleIsRefusedEvenAwayFromTheTarget(t *testing.T) {
manifests := map[string]app.Manifest{
"order": {Code: "order"},
"x": {Code: "x", Requires: []string{"y"}},
"y": {Code: "y", Requires: []string{"x"}},
}
if err := refuseOnDependencyCycle(manifests); err == nil {
t.Fatal("a cycle away from the target was accepted")
}
}
func TestDependencyGraphWithoutACycle(t *testing.T) {
manifests := map[string]app.Manifest{
"a": {Code: "a", Requires: []string{"b", "c"}},
"b": {Code: "b", Requires: []string{"c"}},
"c": {Code: "c"},
// Naming something that is not registered is not a cycle. Whether it
// is installed is a question for the database, at install time.
"d": {Code: "d", Requires: []string{"nowhere"}},
}
if err := refuseOnDependencyCycle(manifests); err != nil {
t.Errorf("a graph with no cycle was refused: %v", err)
}
}
// An application that names itself.
func TestDependencyCycleOfOne(t *testing.T) {
manifests := map[string]app.Manifest{"a": {Code: "a", Requires: []string{"a"}}}
err := refuseOnDependencyCycle(manifests)
if err == nil {
t.Fatal("an application requiring itself was accepted")
}
if !strings.Contains(err.Error(), "a -> a") {
t.Errorf("error = %q", err)
}
}
// The cycle reached from outside it. a is not part of anything circular; b
// and c are. Reporting the walk instead of the cycle would name a as well,
// and sending somebody to look at an application that is not involved is
// the whole reason the path is trimmed.
func TestDependencyCycleReportsOnlyTheCycleItReached(t *testing.T) {
manifests := map[string]app.Manifest{
"a": {Code: "a", Requires: []string{"b"}},
"b": {Code: "b", Requires: []string{"c"}},
"c": {Code: "c", Requires: []string{"b"}},
}
err := refuseOnDependencyCycle(manifests)
if err == nil {
t.Fatal("a cycle was accepted")
}
if !strings.Contains(err.Error(), "b -> c -> b") {
t.Errorf("error = %q, want just the cycle", err)
}
if strings.Contains(err.Error(), "a ->") {
t.Errorf("the walk that reached the cycle was reported as part of it: %q", err)
}
}
+42 -8
View File
@@ -284,12 +284,33 @@ func (e *Migration) Status() ([]StatusEntry, error) {
}
// Migrate applies every registered migration that has not been applied yet,
// across all apps. Existing callers are unaffected.
func (e *Migration) Migrate() { e.run(allApps) }
// across all apps.
func (e *Migration) Migrate() error { return e.run(allApps) }
// MigrateApp applies only the migrations registered under appCode. Pass
// FrameworkAppCode for the framework's own migrations.
func (e *Migration) MigrateApp(appCode string) { e.run(AppFilter(appCode)) }
func (e *Migration) MigrateApp(appCode string) error { return e.run(AppFilter(appCode)) }
// VersionFailure names the migration that failed.
//
// The caller that needs this is an installer recording which version an
// install got stuck on. That is a diagnostic snapshot and nothing more: the
// authoritative answer to "where does a retry resume" is always recomputed
// by subtracting sys_migration's applied rows from what is registered, never
// read back from anywhere it was stored. Which is exactly why this carries
// the version rather than leaving the caller to infer it - inferring it
// would produce "what is pending now", a different question that happens to
// have the same answer most of the time.
type VersionFailure struct {
Version string
Err error
}
func (e *VersionFailure) Error() string {
return fmt.Sprintf("migration %s failed: %v", e.Version, e.Err)
}
func (e *VersionFailure) Unwrap() error { return e.Err }
// NormalizeAppCode applies the same rule ForApp does, so a code typed on the
// command line matches one written in an init().
@@ -332,7 +353,15 @@ func (e *Migration) AppCodes() []string {
return out
}
func (e *Migration) run(appCode string) {
// run applies the pending migrations selected by appCode.
//
// It reports failure instead of ending the process. It used to call
// log.Fatalf, which took the whole process down at the first failing
// migration - so a caller had nowhere to record what happened, and a test
// could not exercise a failing migration at all without killing the test
// binary. The exit now lives at the command layer, where the exit code is
// the command's business (see initDB in cmd/migrate/server.go).
func (e *Migration) run(appCode string) error {
all := e.mergedEntries()
versions := make([]string, 0, len(all))
entries := make(map[string]versionEntry, len(all))
@@ -347,10 +376,14 @@ func (e *Migration) run(appCode string) {
// A mistyped --app would otherwise select nothing and report "no
// migrations to apply", which reads exactly like "already up to date".
//
// The command layer rejects an unregistered code before any database
// work (exitUnlessAppRegistered), so on that path this is unreachable.
// It is reachable from an installer, which asks for one app by name and
// must not be told that installing an app nothing registered succeeded.
if appCode != allApps && len(versions) == 0 {
log.Printf("no migrations are registered for app %q; registered: %s",
return fmt.Errorf("no migrations are registered for app %q; registered: %s",
DisplayAppCode(appCode), strings.Join(e.AppCodes(), ", "))
return
}
var err error
@@ -359,7 +392,7 @@ func (e *Migration) run(appCode string) {
for _, v := range versions {
err = e.db.Table("sys_migration").Where("version = ?", v).Count(&count).Error
if err != nil {
log.Fatalln(err)
return fmt.Errorf("checking whether migration %s was applied: %w", v, err)
}
if count > 0 {
// Already applied. This used to print the bare count, so a mature
@@ -369,7 +402,7 @@ func (e *Migration) run(appCode string) {
}
log.Printf("applying migration %s", v)
if err = entries[v].fn(e.db.Debug(), v); err != nil {
log.Fatalf("migration %s failed: %v", v, err)
return &VersionFailure{Version: v, Err: err}
}
applied++
}
@@ -378,6 +411,7 @@ func (e *Migration) run(appCode string) {
} else {
log.Printf("applied %d migration(s)", applied)
}
return nil
}
// allApps is the sentinel run() takes to mean "do not filter". It is distinct
+90 -28
View File
@@ -1,9 +1,7 @@
package migration
import (
"bytes"
"log"
"os"
"errors"
"strings"
"testing"
"time"
@@ -81,7 +79,9 @@ func TestForAppRecordsItsAppCode(t *testing.T) {
m.ForApp("x").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
return recordFor(db, version, appCode)
})
m.Migrate()
if err := m.Migrate(); err != nil {
t.Fatalf("m.Migrate(): %v", err)
}
rows := rowsByVersion(t, db)
row, ok := rows["x-1786800001000"]
@@ -104,7 +104,9 @@ func TestSetVersionStillRecordsTheFrameworkAsEmpty(t *testing.T) {
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
return db.Create(&common.Migration{Version: version}).Error
})
m.Migrate()
if err := m.Migrate(); err != nil {
t.Fatalf("m.Migrate(): %v", err)
}
rows := rowsByVersion(t, db)
row, ok := rows["1786700009000"]
@@ -136,7 +138,9 @@ func TestMigrateAppRunsOnlyThatApp(t *testing.T) {
return recordFor(db, version, appCode)
})
m.MigrateApp("x")
if err := m.MigrateApp("x"); err != nil {
t.Fatalf("m.MigrateApp(\"x\"): %v", err)
}
if !ran["x"] {
t.Error("x did not run")
@@ -167,7 +171,9 @@ func TestMigrateAppCoreSelectsTheFramework(t *testing.T) {
return recordFor(db, version, appCode)
})
m.MigrateApp(FrameworkAppCode)
if err := m.MigrateApp(FrameworkAppCode); err != nil {
t.Fatalf("m.MigrateApp(FrameworkAppCode): %v", err)
}
if !ran["core"] {
t.Error("framework migration did not run")
@@ -198,7 +204,9 @@ func TestMigrateRunsEveryApp(t *testing.T) {
return recordFor(db, version, appCode)
})
m.Migrate()
if err := m.Migrate(); err != nil {
t.Fatalf("m.Migrate(): %v", err)
}
// Namespacing puts every framework migration - bare digits - ahead of every
// app migration, and orders apps by code rather than by whose timestamp
@@ -232,7 +240,9 @@ func TestNamespacingKeepsTwoAppsWithTheSameTimestampApart(t *testing.T) {
return recordFor(db, version, appCode)
})
}
m.Migrate()
if err := m.Migrate(); err != nil {
t.Fatalf("m.Migrate(): %v", err)
}
if ran != 2 {
t.Errorf("ran %d migrations, want 2", ran)
@@ -357,11 +367,11 @@ func TestFailedMigrationLeavesNoRecord(t *testing.T) {
})
})
// run() calls log.Fatal on failure, which would take the test binary with
// it, so drive the registered function directly - the point here is the
// transaction boundary, not the scheduler.
entry := m.version["crm-1786800001000"]
if err := entry.fn(db, "crm-1786800001000"); err == nil {
// Driven through the scheduler, not by calling the registered function
// directly. That workaround was here because run() called log.Fatal and
// would have taken the test binary with it, which also meant nothing
// covered what the scheduler does with a failure.
if err := m.MigrateApp("crm"); err == nil {
t.Fatal("migration reported success")
}
if rows := rowsByVersion(t, db); len(rows) != 0 {
@@ -369,6 +379,49 @@ func TestFailedMigrationLeavesNoRecord(t *testing.T) {
}
}
// An installer records which version an attempt got stuck on. It gets that
// from the error rather than by asking the database what is still pending,
// which is a different question - see VersionFailure.
func TestRunReportsWhichVersionFailed(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
// Two versions, and the first one succeeds: the failure has to name the
// one that actually failed, which a report that just names the app, or
// the first version it looked at, would get wrong.
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
return recordFor(db, version, appCode)
})
m.ForApp("crm").SetVersion("1786800002000", func(db *gorm.DB, version, appCode string) error {
return errTestMigrationFailed
})
err := m.MigrateApp("crm")
if err == nil {
t.Fatal("MigrateApp reported success")
}
var vf *VersionFailure
if !errors.As(err, &vf) {
t.Fatalf("error is %T, want *VersionFailure: %v", err, err)
}
if vf.Version != "crm-1786800002000" {
t.Errorf("failed version = %q, want crm-1786800002000", vf.Version)
}
if !errors.Is(err, errTestMigrationFailed) {
t.Errorf("the cause is not reachable through the wrapper: %v", err)
}
// The one that succeeded before it stays recorded: a retry must not run
// it again.
rows := rowsByVersion(t, db)
if _, ok := rows["crm-1786800001000"]; !ok {
t.Errorf("the migration that succeeded was not recorded: %v", rows)
}
if _, ok := rows["crm-1786800002000"]; ok {
t.Errorf("the migration that failed was recorded: %v", rows)
}
}
var errTestMigrationFailed = &testError{"boom"}
type testError struct{ s string }
@@ -389,17 +442,18 @@ func TestMigrateAppOnAnUnknownCodeSaysSo(t *testing.T) {
return recordFor(db, version, appCode)
})
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
m.MigrateApp("crmm")
if !strings.Contains(buf.String(), `no migrations are registered for app "crmm"`) {
t.Errorf("output = %q", buf.String())
// Reported as an error rather than a log line, so an installer asking
// for one app by name cannot be told that installing an app nothing
// registered succeeded.
err := m.MigrateApp("crmm")
if err == nil {
t.Fatal("a typo reported success")
}
if !strings.Contains(buf.String(), "registered: core, crm") {
t.Errorf("the message must list what is registered; got %q", buf.String())
if !strings.Contains(err.Error(), `no migrations are registered for app "crmm"`) {
t.Errorf("error = %q", err)
}
if !strings.Contains(err.Error(), "registered: core, crm") {
t.Errorf("the message must list what is registered; got %q", err)
}
if rows := rowsByVersion(t, db); len(rows) != 0 {
t.Errorf("a typo ran %v", rows)
@@ -425,7 +479,9 @@ func TestMergedEntriesRunsAContractRegisteredAppMigration(t *testing.T) {
return recordFor(db, version, appCode)
})
m.Migrate()
if err := m.Migrate(); err != nil {
t.Fatalf("m.Migrate(): %v", err)
}
if !ran {
t.Fatal("contract-registered migration did not run")
@@ -466,7 +522,9 @@ func TestMergedEntriesStatusIncludesContractRegisteredMigrations(t *testing.T) {
t.Fatalf("pending contract entry = %+v (ok=%v)", e, ok)
}
m.Migrate()
if err := m.Migrate(); err != nil {
t.Fatalf("m.Migrate(): %v", err)
}
entries, err = m.Status()
if err != nil {
@@ -522,7 +580,9 @@ func TestMergedEntriesMigrateAppRunsOnlyThatContractApp(t *testing.T) {
return recordFor(db, version, appCode)
})
m.MigrateApp("order")
if err := m.MigrateApp("order"); err != nil {
t.Fatalf("m.MigrateApp(\"order\"): %v", err)
}
if !ran["order"] {
t.Error("order did not run")
@@ -552,7 +612,9 @@ func TestMergedEntriesHostRegistrationWinsOnKeyCollision(t *testing.T) {
return recordFor(db, version, appCode)
})
m.Migrate()
if err := m.Migrate(); err != nil {
t.Fatalf("m.Migrate(): %v", err)
}
if !hostRan {
t.Error("host registration did not run")
+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"
}
}
@@ -3,6 +3,7 @@ package version
import (
"fmt"
"runtime"
"strings"
"gorm.io/gorm"
@@ -47,8 +48,9 @@ func seedNaturalKeys(db *gorm.DB) error {
}
}
if !m.HasIndex(&adminmodels.SysMenu{}, "uk_sys_menu_app_seed_code_del") {
if err := db.Exec(
"CREATE UNIQUE INDEX uk_sys_menu_app_seed_code_del ON sys_menu (app_code, seed_code, deleted_at)",
if err := db.Exec(uniqueIndexOverNullable(db.Dialector.Name(),
"uk_sys_menu_app_seed_code_del", "sys_menu",
"app_code, seed_code, deleted_at", "seed_code"),
).Error; err != nil {
return err
}
@@ -63,8 +65,9 @@ func seedNaturalKeys(db *gorm.DB) error {
return err
}
if !m.HasIndex(&adminmodels.SysApi{}, "uk_sys_api_app_path_action_del") {
if err := db.Exec(
"CREATE UNIQUE INDEX uk_sys_api_app_path_action_del ON sys_api (app_code, path, action, deleted_at)",
if err := db.Exec(uniqueIndexOverNullable(db.Dialector.Name(),
"uk_sys_api_app_path_action_del", "sys_api",
"app_code, path, action, deleted_at", "path", "action"),
).Error; err != nil {
return err
}
@@ -73,6 +76,41 @@ func seedNaturalKeys(db *gorm.DB) error {
return nil
}
// uniqueIndexOverNullable builds a CREATE UNIQUE INDEX whose key includes
// columns that can be NULL, and makes it mean the same thing on all four
// drivers this repository registers.
//
// Three of them treat two NULLs as different values, so any number of rows
// missing one of these columns coexist under the index. SQL Server does not:
// its unique index treats NULLs as equal and permits exactly one. The
// unfiltered statement therefore fails there on any database with two rows
// lacking a seed_code - which is every database, including a brand-new one,
// because 1786700001000 seeds five menus and none of them has one:
//
// Msg 1505 ... duplicate key ... The duplicate key value is (, <NULL>, 0).
//
// Adding the filter on SQL Server takes the rows that carry no value out of
// the index, which is what the other three do by not comparing their NULLs.
// It is not added elsewhere: MySQL has no filtered index at all, and on
// PostgreSQL and SQLite it would only restate what those engines already do.
//
// Only databases that have not applied this migration are affected, and no
// SQL Server database can have: it could not get past this statement.
//
// Takes the dialect by name rather than the connection, so the statement it
// builds for every driver can be checked without one of each running.
func uniqueIndexOverNullable(dialect, name, table, columns string, nullable ...string) string {
stmt := fmt.Sprintf("CREATE UNIQUE INDEX %s ON %s (%s)", name, table, columns)
if dialect != "sqlserver" || len(nullable) == 0 {
return stmt
}
preds := make([]string, 0, len(nullable))
for _, c := range nullable {
preds = append(preds, c+" IS NOT NULL")
}
return stmt + " WHERE " + strings.Join(preds, " AND ")
}
// refuseOnDuplicateApis reports the (app_code, path, action) values that
// would make the unique index impossible, rather than the index failing to
// build and saying only that it did. Only live rows count: a soft-deleted
@@ -0,0 +1,148 @@
package version
import (
"os"
"testing"
"gorm.io/driver/sqlserver"
"gorm.io/gorm"
adminmodels "go-admin/app/admin/models"
)
// sqlserverDSNEnv points these tests at a database. They skip without it, so
// a developer with no SQL Server running still gets a green run.
//
// This file exists for the same reason the PostgreSQL one does, one driver
// further along. The rest of the package runs on SQLite, where the defect it
// covers cannot happen: SQLite, MySQL and PostgreSQL all treat two NULLs in a
// unique index as different values, and SQL Server treats them as equal and
// permits one. A suite that never pointed at SQL Server reported success for
// a migration that could not be applied to any SQL Server database at all,
// new or old.
const sqlserverDSNEnv = "GO_ADMIN_TEST_SQLSERVER_DSN"
func sqlserverDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := os.Getenv(sqlserverDSNEnv)
if dsn == "" {
// Skipping locally is the point; skipping in CI is the failure this
// file exists to prevent.
if os.Getenv("CI") != "" {
t.Fatalf("%s is not set while CI is: the SQL Server migration tests must not skip here", sqlserverDSNEnv)
}
t.Skipf("%s is not set; skipping the SQL Server migration tests", sqlserverDSNEnv)
}
db, err := gorm.Open(sqlserver.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("connecting to %s: %v", sqlserverDSNEnv, err)
}
return db
}
// freshSQLServerTables drops and rebuilds the two tables this migration
// touches, so a rerun does not inherit the previous run's index.
func freshSQLServerTables(t *testing.T, db *gorm.DB) {
t.Helper()
for _, m := range []any{&adminmodels.SysMenu{}, &adminmodels.SysApi{}} {
if db.Migrator().HasTable(m) {
if err := db.Migrator().DropTable(m); err != nil {
t.Fatalf("dropping: %v", err)
}
}
}
if err := db.AutoMigrate(&adminmodels.SysMenu{}, &adminmodels.SysApi{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
}
// The migration completes on SQL Server.
//
// It did not. Five menus with no seed_code is what 1786700001000 leaves on
// every database, and the unfiltered index rejects the second of them:
//
// Msg 1505 ... duplicate key ... The duplicate key value is (, <NULL>, 0).
func TestSeedNaturalKeysOnSQLServer(t *testing.T) {
db := sqlserverDB(t)
freshSQLServerTables(t, db)
// Three rows in the state 1786700006000 leaves behind: an app_code that
// defaulted to empty, no seed_code, and live.
for _, name := range []string{"one", "two", "three"} {
if err := db.Exec(
"INSERT INTO sys_menu (menu_name, app_code, deleted_at) VALUES (?, '', 0)", name,
).Error; err != nil {
t.Fatalf("seeding %s: %v", name, err)
}
}
// sys_api's key has two nullable columns and either one is enough to
// collide, so both shapes are here. Two rows missing both, and two more
// that have a path and no action: a filter naming only path would let
// that second pair back into the index, where their equal NULLs collide.
for i := 0; i < 2; i++ {
if err := db.Exec("INSERT INTO sys_api (app_code, deleted_at) VALUES ('', 0)").Error; err != nil {
t.Fatalf("seeding sys_api: %v", err)
}
if err := db.Exec(
"INSERT INTO sys_api (app_code, path, deleted_at) VALUES ('', '/api/v1/half', 0)",
).Error; err != nil {
t.Fatalf("seeding a sys_api row with no action: %v", err)
}
}
if err := seedNaturalKeys(db); err != nil {
t.Fatalf("seedNaturalKeys on SQL Server: %v", err)
}
for _, name := range []string{"uk_sys_menu_app_seed_code_del", "uk_sys_api_app_path_action_del"} {
var model any = &adminmodels.SysMenu{}
if name == "uk_sys_api_app_path_action_del" {
model = &adminmodels.SysApi{}
}
if !db.Migrator().HasIndex(model, name) {
t.Errorf("%s was not created", name)
}
}
// Rows that do carry a seed code still cannot collide - the filter takes
// the ones with no value out of the index, it does not turn the index off.
code := "dir"
first := adminmodels.SysMenu{MenuName: "d1", AppCode: "order", SeedCode: &code}
if err := db.Create(&first).Error; err != nil {
t.Fatalf("first seeded menu: %v", err)
}
second := adminmodels.SysMenu{MenuName: "d2", AppCode: "order", SeedCode: &code}
if err := db.Create(&second).Error; err == nil {
t.Error("a duplicate (app_code, seed_code) was accepted; the filtered index is not enforcing anything")
}
// A different app may reuse the same seed code, which is why the key is
// composite in the first place.
other := adminmodels.SysMenu{MenuName: "d3", AppCode: "crm", SeedCode: &code}
if err := db.Create(&other).Error; err != nil {
t.Errorf("another app could not reuse the seed code: %v", err)
}
}
// The control. Without the filter the statement fails on this engine, so the
// test above is passing because of the fix rather than because SQL Server
// turned out not to mind.
func TestSQLServerRejectsTheUnfilteredIndex(t *testing.T) {
db := sqlserverDB(t)
freshSQLServerTables(t, db)
for _, name := range []string{"one", "two"} {
if err := db.Exec(
"INSERT INTO sys_menu (menu_name, app_code, deleted_at) VALUES (?, '', 0)", name,
).Error; err != nil {
t.Fatalf("seeding %s: %v", name, err)
}
}
err := db.Exec(uniqueIndexOverNullable("postgres",
"uk_unfiltered_probe", "sys_menu", "app_code, seed_code, deleted_at", "seed_code")).Error
if err == nil {
t.Fatal("SQL Server accepted two NULLs in a unique index; the filter this migration adds is not needed")
}
t.Logf("as expected: %v", err)
}
@@ -0,0 +1,51 @@
package version
import (
"strings"
"testing"
)
// The index has to mean the same thing on every driver this repository
// registers, and the drivers do not agree about NULL.
//
// MySQL, PostgreSQL and SQLite treat two NULLs as different values, so any
// number of rows missing one of these columns coexist under the index. SQL
// Server treats them as equal and permits exactly one, so the unfiltered
// statement fails there on any database with two rows lacking a seed_code -
// which is every database, a brand-new one included, because 1786700001000
// seeds five menus and none of them carries one.
func TestUniqueIndexOverNullableFiltersOnlyWhereItHasTo(t *testing.T) {
const plain = "CREATE UNIQUE INDEX uk ON sys_menu (app_code, seed_code, deleted_at)"
for _, dialect := range []string{"mysql", "postgres", "sqlite"} {
got := uniqueIndexOverNullable(dialect, "uk", "sys_menu", "app_code, seed_code, deleted_at", "seed_code")
if got != plain {
t.Errorf("%s: %q\n want %q", dialect, got, plain)
}
}
got := uniqueIndexOverNullable("sqlserver", "uk", "sys_menu", "app_code, seed_code, deleted_at", "seed_code")
want := plain + " WHERE seed_code IS NOT NULL"
if got != want {
t.Errorf("sqlserver: %q\n want %q", got, want)
}
}
// sys_api's key has two nullable columns, and either one being NULL is enough
// to collide on SQL Server.
func TestUniqueIndexOverNullableCoversEveryNullableColumn(t *testing.T) {
got := uniqueIndexOverNullable("sqlserver", "uk", "sys_api",
"app_code, path, action, deleted_at", "path", "action")
if !strings.HasSuffix(got, " WHERE path IS NOT NULL AND action IS NOT NULL") {
t.Errorf("got %q", got)
}
}
// A key with nothing nullable in it needs no filter anywhere, or SQL Server
// would get a WHERE clause naming no column.
func TestUniqueIndexOverNullableWithoutNullableColumns(t *testing.T) {
got := uniqueIndexOverNullable("sqlserver", "uk", "sys_menu", "app_code, deleted_at")
if strings.Contains(got, "WHERE") {
t.Errorf("got %q", got)
}
}
@@ -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
}
+128 -9
View File
@@ -3,6 +3,7 @@ package migrate
import (
"bytes"
"fmt"
"io"
"os"
"strconv"
"strings"
@@ -14,6 +15,7 @@ import (
"gorm.io/gorm"
"github.com/go-admin-team/go-admin-core/v2/config/source/file"
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/app"
"github.com/spf13/cobra"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
@@ -47,6 +49,31 @@ var (
runStatus()
},
}
// Under migrate rather than under the existing `app` command, which
// already means "generate the skeleton of a new app" - a directory that
// does not exist yet, not an application already compiled into this
// binary. Installing an application is running its migrations, which is
// what this command is; --app, --domain and resolveDB are all already
// here, including the guard that refuses a mistyped code instead of
// reporting a successful no-op.
installCmd = &cobra.Command{
Use: "install <code>",
Short: "Install one application: run its migrations and record it in sys_app",
Example: "go-admin migrate install order -c config/settings.yml",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
runInstall(args[0])
},
}
uninstallCmd = &cobra.Command{
Use: "uninstall <code>",
Short: "Remove one application's menus, apis and permission grants; its own tables are left alone",
Example: "go-admin migrate uninstall order -c config/settings.yml",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
runUninstall(args[0])
},
}
)
// fixme 在您看不见代码的时候运行迁移,我觉得是不安全的,所以编译后最好不要去执行迁移
@@ -64,6 +91,8 @@ func init() {
StartCmd.Flags().BoolVar(&dryRun, "dry-run", false, "list what would be applied, in order, and write nothing")
StartCmd.AddCommand(statusCmd)
StartCmd.AddCommand(installCmd)
StartCmd.AddCommand(uninstallCmd)
}
func run() {
@@ -162,11 +191,9 @@ func migrateModel() error {
}
migration.Migrate.SetDb(db.Debug())
if appCode != "" {
migration.Migrate.MigrateApp(appCode)
return nil
return migration.Migrate.MigrateApp(appCode)
}
migration.Migrate.Migrate()
return nil
return migration.Migrate.Migrate()
}
func initDB() {
@@ -197,13 +224,40 @@ func initDB() {
//4. 数据库迁移
fmt.Println("数据库迁移开始")
if err := migrateModel(); err != nil {
fmt.Println(err)
return
}
exitOnError(os.Stderr, migrateModel())
fmt.Println(`数据库基础数据初始化成功`)
}
// exitOnError ends the command non-zero when the migration did not go through.
//
// A caller that migrates before starting a server decides whether to go ahead
// on the exit code alone - the deploy workflow does exactly that. Every path
// out of migrateModel used to return without one: an unreachable tenant
// database or a failed AutoMigrate printed a line and exited 0, so a
// deployment carried on onto a schema that had not been brought forward. A
// failing migration function was the only one reported, and only because it
// ended the process from inside the migration engine - which is the call this
// batch moved out here, so without this the last reported failure would have
// stopped being reported too.
//
// Split from the exit itself, the way appRegistrationError is split from
// exitUnlessAppRegistered, so what it decides can be tested without a
// subprocess. osExit is a variable for the same reason.
func exitOnError(w io.Writer, err error) {
if err == nil {
return
}
fmt.Fprintln(w, err)
osExit(1)
}
// osExit is a variable so a test can watch the decision without ending the
// test binary; origExit is what it is put back to.
var (
osExit = os.Exit
origExit = os.Exit
)
func runStatus() {
config.Setup(
file.NewSource(file.WithPath(configYml)),
@@ -222,13 +276,78 @@ func runStatus() {
fmt.Println(err)
return
}
if err = printStatus(os.Stdout, entries, appCode); err != nil {
// Which applications exist is a different question from which
// migrations ran, and an install that stopped partway is only
// visible in the answer to the first.
apps, err := loadApps(db)
if err != nil {
fmt.Println(err)
return
}
if err = printStatus(os.Stdout, entries, apps, appCode); err != nil {
fmt.Println(err)
}
},
)
}
func runInstall(code string) {
config.Setup(
file.NewSource(file.WithPath(configYml)),
func() {
database.Setup()
db, err := resolveDB()
if err != nil {
exitOnError(os.Stderr, err)
return
}
registered := app.Snapshot()
m, err := manifestFor(registered, code)
if err != nil {
exitOnError(os.Stderr, err)
return
}
// Over every registered manifest, not just this one's closure: a
// cycle between two other applications is still an authoring
// mistake, and the day somebody installs into it is the worse
// time to find out.
if err := refuseOnDependencyCycle(registered); err != nil {
exitOnError(os.Stderr, err)
return
}
rep, err := install(db, migration.Migrate, m)
if err != nil {
exitOnError(os.Stderr, err)
return
}
reportInstall(os.Stdout, rep)
},
)
}
func runUninstall(code string) {
config.Setup(
file.NewSource(file.WithPath(configYml)),
func() {
database.Setup()
db, err := resolveDB()
if err != nil {
exitOnError(os.Stderr, err)
return
}
// No manifest lookup. An application whose code has already been
// taken out of the binary registers nothing, and that is exactly
// when somebody needs to clear its rows out of the database.
rep, err := uninstall(db, code)
if err != nil {
exitOnError(os.Stderr, err)
return
}
reportUninstall(os.Stdout, rep)
},
)
}
func genFile() error {
t1, err := template.ParseFiles("template/migrate.template")
if err != nil {
+79 -4
View File
@@ -7,6 +7,7 @@ import (
"strings"
"time"
adminmodels "go-admin/app/admin/models"
"go-admin/cmd/migrate/migration"
)
@@ -15,11 +16,24 @@ const applyTimeLayout = "2006-01-02 15:04:05"
// printStatus lists every migration this binary knows about together with every
// row already in sys_migration, grouped by app.
//
// apps is what sys_app says about each of them, keyed by app code, and it
// answers a different question from the migration rows: an install that
// stopped partway leaves migrations that all read "applied" and a row that
// says the install never finished. A nil map is a database from before
// sys_app existed, and the listing is then exactly what it was.
//
// The app list is the union of the two. Reading it from sys_app alone would
// drop an application whose migrations ran under plain `migrate` and which
// therefore has no row; reading it from the migration rows alone drops one
// whose code has been taken out of the binary, which is when somebody most
// wants to see it named.
//
// filter is an app code as typed on the command line; empty means every app.
func printStatus(w io.Writer, entries []migration.StatusEntry, filter string) error {
func printStatus(w io.Writer, entries []migration.StatusEntry, apps map[string]adminmodels.SysApp, filter string) error {
entries = filterByApp(entries, filter)
apps = filterAppsByApp(apps, filter)
groups, order := groupByApp(entries)
groups, order := groupByApp(entries, apps)
if len(order) == 0 {
_, err := fmt.Fprintln(w, "no migrations registered and none recorded")
return err
@@ -34,7 +48,14 @@ func printStatus(w io.Writer, entries []migration.StatusEntry, filter string) er
if i > 0 {
fmt.Fprintln(w)
}
fmt.Fprintf(w, "[%s]\n", app)
fmt.Fprintf(w, "[%s]%s\n", app, appSummary(apps, app))
if len(groups[app]) == 0 {
// A row in sys_app and not one migration, recorded or
// registered. Its code is out of this binary and its migration
// records have been removed, and the row is all that is left to
// say it was ever here.
fmt.Fprintln(w, " no migrations registered in this binary and none recorded")
}
for _, e := range groups[app] {
state := "pending"
switch {
@@ -133,12 +154,21 @@ func filterByApp(entries []migration.StatusEntry, filter string) []migration.Sta
// order to print them in: the framework first, then apps alphabetically. That
// is also the order a full run executes them in, because version strings sort
// as ASCII and the framework's are bare digits.
func groupByApp(entries []migration.StatusEntry) (map[string][]migration.StatusEntry, []string) {
func groupByApp(entries []migration.StatusEntry, apps map[string]adminmodels.SysApp) (map[string][]migration.StatusEntry, []string) {
groups := make(map[string][]migration.StatusEntry)
for _, e := range entries {
app := migration.DisplayAppCode(e.AppCode)
groups[app] = append(groups[app], e)
}
// An application sys_app knows about and no migration mentions still gets
// a group, empty. That is the one case the migration rows cannot report
// at all.
for code := range apps {
app := migration.DisplayAppCode(code)
if _, ok := groups[app]; !ok {
groups[app] = nil
}
}
order := make([]string, 0, len(groups))
for app := range groups {
order = append(order, app)
@@ -152,6 +182,51 @@ func groupByApp(entries []migration.StatusEntry) (map[string][]migration.StatusE
return groups, order
}
// appSummary is what sys_app says about one application, as a suffix for its
// group header. Empty when there is no row: an application whose migrations
// ran under plain `migrate` has none, and neither does any application on a
// database from before sys_app existed.
func appSummary(apps map[string]adminmodels.SysApp, display string) string {
// AppFilter, not NormalizeAppCode: this takes a display code back to the
// stored one, and only AppFilter is that inverse. It maps the framework
// to the empty string, which loadApps never files a row under, so the
// framework needs no branch of its own here.
row, ok := apps[migration.AppFilter(display)]
if !ok {
return ""
}
switch row.Status {
case adminmodels.AppInstalled:
return fmt.Sprintf(" %s installed", row.Version)
case adminmodels.AppFailed:
if row.FailedVersion != "" {
return fmt.Sprintf(" %s failed at %s", row.Version, row.FailedVersion)
}
return fmt.Sprintf(" %s failed", row.Version)
case adminmodels.AppInstalling:
// Not "installing" as in "right now": nothing holds this state while
// it works. It is what is left when an attempt did not reach either
// end, and running the install again is what clears it.
return fmt.Sprintf(" %s did not finish installing", row.Version)
default:
return fmt.Sprintf(" %s status %d", row.Version, row.Status)
}
}
// filterAppsByApp narrows the sys_app rows the same way filterByApp narrows
// the migrations, so --app names one application in both halves of the report.
func filterAppsByApp(apps map[string]adminmodels.SysApp, filter string) map[string]adminmodels.SysApp {
if filter == "" {
return apps
}
want := migration.AppFilter(filter)
out := make(map[string]adminmodels.SysApp, 1)
if row, ok := apps[want]; ok {
out[want] = row
}
return out
}
func formatApplyTime(t *time.Time) string {
if t == nil {
return ""
+119 -5
View File
@@ -6,6 +6,7 @@ import (
"testing"
"time"
adminmodels "go-admin/app/admin/models"
"go-admin/cmd/migrate/migration"
)
@@ -29,7 +30,7 @@ func sampleEntries() []migration.StatusEntry {
func TestPrintStatusGroupsByApp(t *testing.T) {
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), ""); err != nil {
if err := printStatus(&buf, sampleEntries(), nil, ""); err != nil {
t.Fatal(err)
}
got := buf.String()
@@ -62,7 +63,7 @@ func TestPrintStatusMarksOrphanedRows(t *testing.T) {
Version: "gone-1786800000000", AppCode: "gone", Applied: true, ApplyTime: at("2026-08-01 09:00:00"),
})
var buf bytes.Buffer
if err := printStatus(&buf, entries, ""); err != nil {
if err := printStatus(&buf, entries, nil, ""); err != nil {
t.Fatal(err)
}
got := buf.String()
@@ -79,7 +80,7 @@ func TestPrintStatusMarksOrphanedRows(t *testing.T) {
func TestPrintStatusFiltersByApp(t *testing.T) {
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), "crm"); err != nil {
if err := printStatus(&buf, sampleEntries(), nil, "crm"); err != nil {
t.Fatal(err)
}
got := buf.String()
@@ -94,7 +95,7 @@ func TestPrintStatusFiltersByApp(t *testing.T) {
// status prints [core]; --app core has to mean the same thing.
func TestPrintStatusAppCoreSelectsTheFramework(t *testing.T) {
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), migration.FrameworkAppCode); err != nil {
if err := printStatus(&buf, sampleEntries(), nil, migration.FrameworkAppCode); err != nil {
t.Fatal(err)
}
got := buf.String()
@@ -108,7 +109,7 @@ func TestPrintStatusAppCoreSelectsTheFramework(t *testing.T) {
func TestPrintStatusOnAnEmptyRegistry(t *testing.T) {
var buf bytes.Buffer
if err := printStatus(&buf, nil, ""); err != nil {
if err := printStatus(&buf, nil, nil, ""); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "no migrations registered and none recorded") {
@@ -168,3 +169,116 @@ func TestPrintPendingFiltersByApp(t *testing.T) {
t.Errorf("output = %s", got)
}
}
func appRows(rows ...adminmodels.SysApp) map[string]adminmodels.SysApp {
out := make(map[string]adminmodels.SysApp, len(rows))
for _, r := range rows {
out[r.AppCode] = r
}
return out
}
// The migration rows say every one of an application's migrations ran. Only
// sys_app can say the install that ran them never finished.
func TestPrintStatusShowsWhatSysAppSays(t *testing.T) {
apps := appRows(
adminmodels.SysApp{AppCode: "crm", Version: "1.2.0", Status: adminmodels.AppInstalled},
)
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), apps, ""); err != nil {
t.Fatal(err)
}
got := buf.String()
if !strings.Contains(got, "[crm] 1.2.0 installed") {
t.Errorf("the header does not carry what sys_app says:\n%s", got)
}
// The framework is not an application and has no row.
if strings.Contains(got, "[core] ") {
t.Errorf("the framework was given an application summary:\n%s", got)
}
}
func TestPrintStatusNamesAFailedInstallAndWhereItStopped(t *testing.T) {
apps := appRows(adminmodels.SysApp{
AppCode: "crm", Version: "1.2.0", Status: adminmodels.AppFailed,
FailedVersion: "crm-1786800002000",
})
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), apps, ""); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "[crm] 1.2.0 failed at crm-1786800002000") {
t.Errorf("output:\n%s", buf.String())
}
}
// A process killed partway leaves this, and nothing else records it.
func TestPrintStatusNamesAnInstallThatDidNotFinish(t *testing.T) {
apps := appRows(adminmodels.SysApp{AppCode: "crm", Version: "1.2.0", Status: adminmodels.AppInstalling})
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), apps, ""); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "did not finish installing") {
t.Errorf("output:\n%s", buf.String())
}
}
// An application whose code was taken out of the binary after its migration
// records were removed has nothing left but a sys_app row. The migration rows
// cannot report it at all.
func TestPrintStatusListsAnAppWithNoMigrationsAtAll(t *testing.T) {
apps := appRows(adminmodels.SysApp{AppCode: "billing", Version: "3.0.0", Status: adminmodels.AppInstalled})
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), apps, ""); err != nil {
t.Fatal(err)
}
got := buf.String()
if !strings.Contains(got, "[billing] 3.0.0 installed") {
t.Errorf("an application only sys_app knows about was not listed:\n%s", got)
}
if !strings.Contains(got, "no migrations registered in this binary and none recorded") {
t.Errorf("the empty group needs to say why it is empty:\n%s", got)
}
if !strings.Contains(got, "across 3 app(s)") {
t.Errorf("the count does not include it:\n%s", got)
}
}
// --app narrows both halves, or the report names one application and
// summarises another.
func TestPrintStatusFilterAppliesToSysAppToo(t *testing.T) {
apps := appRows(
adminmodels.SysApp{AppCode: "crm", Version: "1.2.0", Status: adminmodels.AppInstalled},
adminmodels.SysApp{AppCode: "billing", Version: "3.0.0", Status: adminmodels.AppInstalled},
)
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), apps, "crm"); err != nil {
t.Fatal(err)
}
got := buf.String()
if strings.Contains(got, "billing") {
t.Errorf("--app crm listed billing:\n%s", got)
}
if !strings.Contains(got, "across 1 app(s)") {
t.Errorf("output:\n%s", got)
}
}
// A database from before sys_app existed. The listing is what it always was,
// rather than an error or an empty report.
func TestPrintStatusWithoutSysApp(t *testing.T) {
var withRows, without bytes.Buffer
if err := printStatus(&without, sampleEntries(), nil, ""); err != nil {
t.Fatal(err)
}
if err := printStatus(&withRows, sampleEntries(), map[string]adminmodels.SysApp{}, ""); err != nil {
t.Fatal(err)
}
if without.String() != withRows.String() {
t.Errorf("an empty sys_app and no sys_app print differently:\n%s\n---\n%s", without.String(), withRows.String())
}
if !strings.Contains(without.String(), "[crm]\n") {
t.Errorf("the header carries a summary it has no row for:\n%s", without.String())
}
}
+296
View File
@@ -0,0 +1,296 @@
package migrate
import (
"errors"
"fmt"
"io"
"strings"
"gorm.io/gorm"
adminmodels "go-admin/app/admin/models"
"go-admin/cmd/migrate/migration"
commonmodels "go-admin/common/models"
)
// policyKey is one casbin_rule row identified the way casbin_rule is unique:
// by its tuple, not by its id. Ids do not survive SysRole.Update, which
// removes a role's policies and adds them back.
type policyKey struct {
Ptype string `gorm:"column:ptype"`
V0 string `gorm:"column:v0"`
V1 string `gorm:"column:v1"`
V2 string `gorm:"column:v2"`
V3 string `gorm:"column:v3"`
V4 string `gorm:"column:v4"`
V5 string `gorm:"column:v5"`
}
func (p policyKey) String() string {
return fmt.Sprintf("%s %s %s %s", p.Ptype, p.V0, p.V1, p.V2)
}
// uninstallReport is what an uninstall removed, and what it deliberately did
// not.
type uninstallReport struct {
Code string
// Found says whether sys_app had a row. An application whose migrations
// were applied by plain `migrate` rather than by `install` has its menus
// and its permissions without ever having had one.
Found bool
Version string
Menus int64
Apis int64
Bindings int64
RoleMenus int64
Policies int64
Migrations int64
// Skipped are ledger entries whose casbin_rule row was not there any
// more: something this install created and something else removed.
Skipped []policyKey
// Orphans are policies naming this application's paths that no ledger
// entry claims - somebody granted this app's API to another role by
// hand. Reported, never deleted.
Orphans []policyKey
}
// uninstall removes one application's menus, APIs and permission grants.
//
// It does not touch the application's own tables. Removing an order module
// is not the same decision as destroying the orders, and nothing here can
// tell the operator apart from someone who will reinstall tomorrow.
//
// One transaction, and this one really is one: every statement below is DML
// or a SELECT, so unlike an install there is no DDL to commit it out from
// under itself. Child rows go first, while the ids that identify them can
// still be read from the parents.
//
// A sys_app row is not required. `migrate` with no subcommand applies every
// registered migration, an application's included, so an application can
// have all of its data without ever having gone through the installer.
func uninstall(db *gorm.DB, code string) (uninstallReport, error) {
code = migration.NormalizeAppCode(code)
rep := uninstallReport{Code: code}
if code == "" {
return rep, errors.New("no app code given")
}
if code == migration.FrameworkAppCode {
return rep, fmt.Errorf("%q is the framework's own migrations; there is no uninstall for those", code)
}
err := db.Transaction(func(tx *gorm.DB) error {
row, found, err := loadApp(tx, code)
if err != nil {
return err
}
rep.Found = found
if found {
rep.Version = row.Version
}
// 1 and 2. Read before deleting: sys_api's rows are about to go, and
// step 5b needs their paths.
//
// Unscoped throughout. A row this application wrote that somebody
// soft-deleted from the UI is still this application's row, and
// leaving it behind would leave its join rows pointing at it.
var menuIDs []int
if err := tx.Unscoped().Model(&adminmodels.SysMenu{}).
Where("app_code = ?", code).Pluck("menu_id", &menuIDs).Error; err != nil {
return fmt.Errorf("reading this app's menus: %w", err)
}
var apiIDs []int
if err := tx.Unscoped().Model(&adminmodels.SysApi{}).
Where("app_code = ?", code).Pluck("id", &apiIDs).Error; err != nil {
return fmt.Errorf("reading this app's apis: %w", err)
}
var apiKeys []policyKey
if err := tx.Unscoped().Model(&adminmodels.SysApi{}).
Where("app_code = ?", code).
Select("path as v1, action as v2").Scan(&apiKeys).Error; err != nil {
return fmt.Errorf("reading this app's api paths: %w", err)
}
// 3. The many2many rows behind SysMenu.SysApi. Either side is enough
// to make a row this application's.
//
// The guard is intent, not necessity: GORM renders IN with an empty
// slice as a condition matching nothing rather than the empty IN
// list raw SQL would reject, so removing it changes no behaviour
// today. It says out loud that an application with no menus, or no
// apis, is a normal thing to uninstall.
if len(menuIDs) > 0 || len(apiIDs) > 0 {
q := tx.Table("sys_menu_api_rule")
switch {
case len(menuIDs) > 0 && len(apiIDs) > 0:
q = q.Where("sys_menu_menu_id IN ? OR sys_api_id IN ?", menuIDs, apiIDs)
case len(menuIDs) > 0:
q = q.Where("sys_menu_menu_id IN ?", menuIDs)
default:
q = q.Where("sys_api_id IN ?", apiIDs)
}
res := q.Delete(nil)
if res.Error != nil {
return fmt.Errorf("removing menu/api bindings: %w", res.Error)
}
rep.Bindings = res.RowsAffected
}
// 4. Role assignments. menu_id is a surrogate key, so a row here can
// only have come from a menu this application wrote - there is no
// "looks like it but is not". That is why this needs no ledger, and
// why a column on sys_role_menu would have been wrong: SysRole.Update
// deletes a role's rows and writes them back through GORM's
// many2many, which does not carry extra columns, so any such column
// would be silently blanked the first time somebody edits a role.
if len(menuIDs) > 0 {
res := tx.Table("sys_role_menu").Where("menu_id IN ?", menuIDs).Delete(nil)
if res.Error != nil {
return fmt.Errorf("removing role assignments: %w", res.Error)
}
rep.RoleMenus = res.RowsAffected
}
// 5. Policies, by ledger, one at a time and by exact tuple.
var grants []adminmodels.SysAppCasbinGrant
if err := tx.Where("app_code = ?", code).Find(&grants).Error; err != nil {
return fmt.Errorf("reading the grant ledger: %w", err)
}
for _, g := range grants {
k := policyKey{Ptype: g.Ptype, V0: g.V0, V1: g.V1, V2: g.V2, V3: g.V3, V4: g.V4, V5: g.V5}
res := tx.Table("casbin_rule").
Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ? AND v3 = ? AND v4 = ? AND v5 = ?",
k.Ptype, k.V0, k.V1, k.V2, k.V3, k.V4, k.V5).
Delete(nil)
if res.Error != nil {
return fmt.Errorf("removing policy %s: %w", k, res.Error)
}
if res.RowsAffected == 0 {
// Something this install created is not there any more. Not
// an error: the uninstall's job was to remove it and it is
// gone. Reported because a policy this app created and did
// not remove means something else rewrote casbin_rule.
rep.Skipped = append(rep.Skipped, k)
continue
}
rep.Policies += res.RowsAffected
}
// The ledger's job ends here whether or not each row matched. Left
// behind it would only grow, and a reinstall writes its own entries.
if err := tx.Where("app_code = ?", code).
Delete(&adminmodels.SysAppCasbinGrant{}).Error; err != nil {
return fmt.Errorf("clearing the grant ledger: %w", err)
}
// 5b. Read-only. By now every policy the ledger could speak for has
// been dealt with, so a policy still matching one of this app's paths
// is one the ledger never claimed - somebody granted this app's API
// to another role by hand. Business rule 3 says do not delete what
// is not ours; without this step nobody would ever learn it is
// there, pointing at an API that is about to stop existing.
orphans, err := findOrphanPolicies(tx, apiKeys)
if err != nil {
return err
}
rep.Orphans = orphans
// 6 and 7.
res := tx.Unscoped().Where("app_code = ?", code).Delete(&adminmodels.SysApi{})
if res.Error != nil {
return fmt.Errorf("removing this app's apis: %w", res.Error)
}
rep.Apis = res.RowsAffected
res = tx.Unscoped().Where("app_code = ?", code).Delete(&adminmodels.SysMenu{})
if res.Error != nil {
return fmt.Errorf("removing this app's menus: %w", res.Error)
}
rep.Menus = res.RowsAffected
// 8. Without this a reinstall finds every version already applied,
// runs no migration, seeds nothing, and reports success. It is the
// easiest step to leave out, because a migration record does not
// look like the application's data.
res = tx.Where("app_code = ?", code).Delete(&commonmodels.Migration{})
if res.Error != nil {
return fmt.Errorf("removing this app's migration records: %w", res.Error)
}
rep.Migrations = res.RowsAffected
// 9.
if found {
if err := tx.Where("app_code = ?", code).
Delete(&adminmodels.SysApp{}).Error; err != nil {
return fmt.Errorf("removing the sys_app row: %w", err)
}
}
return nil
})
if err != nil {
return uninstallReport{Code: code}, err
}
return rep, nil
}
// findOrphanPolicies looks for policies naming any of this application's
// paths.
//
// Written as an OR chain rather than a row-value IN, which MySQL and modern
// SQLite accept and SQL Server does not; this repository supports all of
// them. Chunked because a driver's placeholder limit is reached long before
// an application runs out of endpoints.
func findOrphanPolicies(tx *gorm.DB, keys []policyKey) ([]policyKey, error) {
const chunk = 100
var out []policyKey
for start := 0; start < len(keys); start += chunk {
end := start + chunk
if end > len(keys) {
end = len(keys)
}
clauses := make([]string, 0, end-start)
args := make([]any, 0, (end-start)*2)
for _, k := range keys[start:end] {
clauses = append(clauses, "(v1 = ? AND v2 = ?)")
args = append(args, k.V1, k.V2)
}
var found []policyKey
if err := tx.Table("casbin_rule").
Where("ptype = ? AND ("+strings.Join(clauses, " OR ")+")", append([]any{"p"}, args...)...).
Scan(&found).Error; err != nil {
return nil, fmt.Errorf("looking for policies nothing claims: %w", err)
}
out = append(out, found...)
}
return out, nil
}
// reportUninstall prints what went and what stayed.
//
// The two lists are separate because they mean different things: one is
// something this application created that had already gone, the other is
// somebody else's grant that is now pointing at nothing. Merged into one
// "could not remove" list, neither would be actionable.
func reportUninstall(w io.Writer, rep uninstallReport) {
if !rep.Found {
fmt.Fprintf(w, "%s had no sys_app row; removed what its migrations had written\n", rep.Code)
} else {
fmt.Fprintf(w, "uninstalled %s %s\n", rep.Code, rep.Version)
}
fmt.Fprintf(w, "removed: %d menu(s), %d api(s), %d binding(s), %d role assignment(s), %d policy(ies), %d migration record(s)\n",
rep.Menus, rep.Apis, rep.Bindings, rep.RoleMenus, rep.Policies, rep.Migrations)
fmt.Fprintln(w, "the application's own tables were not touched.")
if len(rep.Skipped) > 0 {
fmt.Fprintf(w, "\n%d policy(ies) this install had created were already gone:\n", len(rep.Skipped))
for _, k := range rep.Skipped {
fmt.Fprintf(w, " %s\n", k)
}
}
if len(rep.Orphans) > 0 {
fmt.Fprintf(w, "\n%d policy(ies) name this application's paths and were granted by somebody else, so they were left alone:\n", len(rep.Orphans))
for _, k := range rep.Orphans {
fmt.Fprintf(w, " %s\n", k)
}
fmt.Fprintln(w, "they now point at APIs that no longer exist. Harmless to the running server, and yours to clear up.")
}
}
+500
View File
@@ -0,0 +1,500 @@
package migrate
import (
"strconv"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed"
"gorm.io/gorm"
"gorm.io/gorm/logger"
adminmodels "go-admin/app/admin/models"
_ "go-admin/app/admin/service" // registers the seeder SeedMenus dispatches to
"go-admin/cmd/migrate/migration"
commonmodels "go-admin/common/models"
)
const adminRoleKey = "admin"
// newUninstallDB builds every table an install writes to, plus one table
// standing in for the application's own data, which an uninstall must not
// touch.
func newUninstallDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(
&adminmodels.SysMenu{}, &adminmodels.SysApi{}, &adminmodels.SysRole{},
&adminmodels.SysApp{}, &adminmodels.SysAppCasbinGrant{}, &commonmodels.Migration{},
); err != nil {
t.Fatalf("automigrate: %v", err)
}
// casbin_rule has no GORM model in this repository; the columns are the
// ones grantToAdminRole's INSERT addresses.
if err := db.Exec(`CREATE TABLE casbin_rule (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ptype TEXT, v0 TEXT, v1 TEXT, v2 TEXT, v3 TEXT, v4 TEXT, v5 TEXT
)`).Error; err != nil {
t.Fatalf("create casbin_rule: %v", err)
}
if err := db.Exec(`CREATE TABLE app_order (id INTEGER PRIMARY KEY, note TEXT)`).Error; err != nil {
t.Fatalf("create app_order: %v", err)
}
if err := db.Exec(`INSERT INTO app_order (id, note) VALUES (1, 'a real order')`).Error; err != nil {
t.Fatalf("seed app_order: %v", err)
}
if err := db.Create(&adminmodels.SysRole{RoleName: "Administrator", RoleKey: adminRoleKey}).Error; err != nil {
t.Fatalf("seed admin role: %v", err)
}
return db
}
// specsFor builds one application's menus and apis. The paths carry the app
// code, because two applications do not share an endpoint - and if a fixture
// let them, the second one's policies would already exist and its ledger
// would legitimately come out empty, which would make it a useless control.
func specsFor(code string) ([]seed.MenuSpec, []seed.ApiSpec) {
menus := []seed.MenuSpec{
{Code: "dir", Kind: "M", Title: code + " example", Path: "/apps/" + code, Component: "Layout", Sort: 10},
{Code: "list", Parent: "dir", Kind: "C", Title: code, Path: "list", Component: "apps/" + code + "/index", Sort: 1, ApiCodes: []string{"list"}},
}
apis := []seed.ApiSpec{
{Code: "list", Title: code + " list", Path: "/api/v1/" + code, Method: "GET", Handle: "apis." + code + ".GetPage-fm"},
{Code: "create", Title: "create " + code, Path: "/api/v1/" + code, Method: "POST", Handle: "apis." + code + ".Insert-fm"},
}
return menus, apis
}
// seedApp runs the real seeding path, so what the uninstaller has to undo is
// what an install actually writes rather than a hand-built approximation.
func seedApp(t *testing.T, db *gorm.DB, code string) {
t.Helper()
menus, apis := specsFor(code)
if err := db.Transaction(func(tx *gorm.DB) error {
return seed.SeedMenus(tx, code, menus, apis)
}); err != nil {
t.Fatalf("seeding %q: %v", code, err)
}
if err := db.Create(&commonmodels.Migration{
Version: code + "-1786800001000", AppCode: code, ApplyTime: time.Now(),
}).Error; err != nil {
t.Fatalf("recording the migration for %q: %v", code, err)
}
appRow(t, db, code, adminmodels.AppInstalled)
}
func count(t *testing.T, db *gorm.DB, table, where string, args ...any) int64 {
t.Helper()
var n int64
q := db.Table(table)
if where != "" {
q = q.Where(where, args...)
}
if err := q.Count(&n).Error; err != nil {
t.Fatalf("counting %s: %v", table, err)
}
return n
}
// A3: everything the install wrote goes, and the application's own table does
// not.
func TestUninstallRemovesWhatWasSeededAndNothingElse(t *testing.T) {
db := newUninstallDB(t)
seedApp(t, db, "order")
if count(t, db, "sys_menu", "app_code = ?", "order") == 0 {
t.Fatal("nothing was seeded, so this test proves nothing")
}
rep, err := uninstall(db, "order")
if err != nil {
t.Fatalf("uninstall: %v", err)
}
if !rep.Found {
t.Error("the sys_app row was not found")
}
for _, c := range []struct {
table, where string
args []any
}{
{"sys_menu", "app_code = ?", []any{"order"}},
{"sys_api", "app_code = ?", []any{"order"}},
{"sys_menu_api_rule", "", nil},
{"sys_role_menu", "", nil},
{"casbin_rule", "v1 = ?", []any{"/api/v1/order"}},
{"sys_app_casbin_grant", "app_code = ?", []any{"order"}},
{"sys_migration", "app_code = ?", []any{"order"}},
{"sys_app", "app_code = ?", []any{"order"}},
} {
if n := count(t, db, c.table, c.where, c.args...); n != 0 {
t.Errorf("%s still has %d row(s)", c.table, n)
}
}
if n := count(t, db, "app_order", "", nil); n != 1 {
t.Errorf("app_order has %d row(s); the application's own data is not the uninstaller's to remove", n)
}
if len(rep.Skipped) != 0 || len(rep.Orphans) != 0 {
t.Errorf("a clean uninstall reported skipped=%v orphans=%v", rep.Skipped, rep.Orphans)
}
if rep.Menus == 0 || rep.Apis == 0 || rep.Policies == 0 || rep.Migrations == 0 {
t.Errorf("the report says nothing was removed: %+v", rep)
}
}
// Uninstalling one application must not reach into another's rows. Every
// delete here is filtered, and a missing filter is invisible on a database
// with only one application in it.
func TestUninstallLeavesAnotherApplicationAlone(t *testing.T) {
db := newUninstallDB(t)
seedApp(t, db, "order")
seedApp(t, db, "crm")
before := map[string]int64{
"sys_menu": count(t, db, "sys_menu", "app_code = ?", "crm"),
"sys_api": count(t, db, "sys_api", "app_code = ?", "crm"),
"sys_app_casbin_grant": count(t, db, "sys_app_casbin_grant", "app_code = ?", "crm"),
"sys_migration": count(t, db, "sys_migration", "app_code = ?", "crm"),
"sys_app": count(t, db, "sys_app", "app_code = ?", "crm"),
}
for k, v := range before {
if v == 0 {
t.Fatalf("crm has no rows in %s, so this test proves nothing", k)
}
}
crmBindings := count(t, db, "sys_menu_api_rule", "", nil)
crmRoleMenus := count(t, db, "sys_role_menu", "", nil)
if _, err := uninstall(db, "order"); err != nil {
t.Fatalf("uninstall: %v", err)
}
for k, v := range before {
if n := count(t, db, k, "app_code = ?", "crm"); n != v {
t.Errorf("%s for crm went from %d to %d", k, v, n)
}
}
// crm's own bindings and role rows are half of each total, and must be
// exactly what is left.
if n := count(t, db, "sys_menu_api_rule", "", nil); n != crmBindings/2 {
t.Errorf("sys_menu_api_rule = %d, want %d (crm's half)", n, crmBindings/2)
}
if n := count(t, db, "sys_role_menu", "", nil); n != crmRoleMenus/2 {
t.Errorf("sys_role_menu = %d, want %d (crm's half)", n, crmRoleMenus/2)
}
// crm's policies name a different path, so they are untouched.
if n := count(t, db, "casbin_rule", "v1 = ?", "/api/v1/order"); n != 0 {
t.Errorf("order's policies survived: %d", n)
}
}
// A6b: somebody granted this application's API to another role by hand. That
// grant is not in the ledger, is not this uninstall's to remove, and would
// otherwise vanish from view entirely.
func TestUninstallReportsAGrantSomebodyElseMade(t *testing.T) {
db := newUninstallDB(t)
seedApp(t, db, "order")
if err := db.Exec(
"INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'ops', '/api/v1/order', 'GET', '', '', '')",
).Error; err != nil {
t.Fatalf("hand-made grant: %v", err)
}
rep, err := uninstall(db, "order")
if err != nil {
t.Fatalf("uninstall: %v", err)
}
if n := count(t, db, "casbin_rule", "v0 = ?", "ops"); n != 1 {
t.Errorf("somebody else's grant was deleted (%d rows left)", n)
}
if len(rep.Orphans) != 1 {
t.Fatalf("orphans = %v, want the one hand-made grant", rep.Orphans)
}
if rep.Orphans[0].V0 != "ops" {
t.Errorf("orphan = %+v", rep.Orphans[0])
}
// The admin grants it did own are gone.
if n := count(t, db, "casbin_rule", "v0 = ?", adminRoleKey); n != 0 {
t.Errorf("%d of this app's own policies survived", n)
}
var out strings.Builder
reportUninstall(&out, rep)
if !strings.Contains(out.String(), "ops") || !strings.Contains(out.String(), "left alone") {
t.Errorf("the report does not say what was left behind: %q", out.String())
}
}
// A6a: a policy this install created is not there any more. Not an error -
// the uninstall wanted it gone and it is - but reported, because something
// else rewrote casbin_rule.
func TestUninstallReportsALedgerEntryWhosePolicyIsGone(t *testing.T) {
db := newUninstallDB(t)
seedApp(t, db, "order")
if err := db.Exec("DELETE FROM casbin_rule WHERE v2 = 'POST'").Error; err != nil {
t.Fatalf("removing a policy: %v", err)
}
rep, err := uninstall(db, "order")
if err != nil {
t.Fatalf("a missing policy made the uninstall fail: %v", err)
}
if len(rep.Skipped) != 1 {
t.Fatalf("skipped = %v, want the one that had gone", rep.Skipped)
}
if rep.Skipped[0].V2 != "POST" {
t.Errorf("skipped = %+v", rep.Skipped[0])
}
if n := count(t, db, "sys_app_casbin_grant", "", nil); n != 0 {
t.Errorf("the ledger kept %d row(s); its job ends with the uninstall", n)
}
// It still committed: a skip is a reported branch, not a failure.
if n := count(t, db, "sys_menu", "app_code = ?", "order"); n != 0 {
t.Errorf("the transaction rolled back over a skip: sys_menu has %d row(s)", n)
}
}
// G5/A4: without this the reinstall finds every version applied, runs no
// migration, seeds nothing, and reports success.
func TestUninstallClearsThisAppsMigrationRecordsOnly(t *testing.T) {
db := newUninstallDB(t)
seedApp(t, db, "order")
if err := db.Create(&commonmodels.Migration{
Version: "1786700001000", AppCode: "", ApplyTime: time.Now(),
}).Error; err != nil {
t.Fatalf("framework migration row: %v", err)
}
if _, err := uninstall(db, "order"); err != nil {
t.Fatalf("uninstall: %v", err)
}
if n := count(t, db, "sys_migration", "app_code = ?", "order"); n != 0 {
t.Errorf("sys_migration still has %d row(s) for order; a reinstall would seed nothing", n)
}
if n := count(t, db, "sys_migration", "app_code = ?", ""); n != 1 {
t.Errorf("the framework's own migration record was removed (%d left)", n)
}
}
// A11: sys_role_menu is found by menu id, not by a column on it. A column
// would have been blanked the first time somebody edited a role, because
// SysRole.Update deletes the role's rows and writes them back through GORM's
// many2many, which does not carry extra columns. This reproduces that edit.
func TestUninstallSurvivesARoleMenuRewrite(t *testing.T) {
db := newUninstallDB(t)
seedApp(t, db, "order")
var roleID int
if err := db.Model(&adminmodels.SysRole{}).Where("role_key = ?", adminRoleKey).
Pluck("role_id", &roleID).Error; err != nil {
t.Fatalf("reading the admin role: %v", err)
}
var menuIDs []int
if err := db.Model(&adminmodels.SysMenu{}).Where("app_code = ?", "order").
Pluck("menu_id", &menuIDs).Error; err != nil {
t.Fatalf("reading menus: %v", err)
}
if len(menuIDs) == 0 {
t.Fatal("no menus were seeded")
}
// What SysRole.Update does: drop every row for the role, then write them
// back with nothing but the two keys.
if err := db.Exec("DELETE FROM sys_role_menu WHERE role_id = ?", roleID).Error; err != nil {
t.Fatalf("clearing role menus: %v", err)
}
for _, id := range menuIDs {
if err := db.Exec("INSERT INTO sys_role_menu (role_id, menu_id) VALUES (?, ?)", roleID, id).Error; err != nil {
t.Fatalf("rewriting role menus: %v", err)
}
}
rep, err := uninstall(db, "order")
if err != nil {
t.Fatalf("uninstall: %v", err)
}
if rep.RoleMenus != int64(len(menuIDs)) {
t.Errorf("removed %d role assignment(s), want %d", rep.RoleMenus, len(menuIDs))
}
if n := count(t, db, "sys_role_menu", "", nil); n != 0 {
t.Errorf("sys_role_menu still has %d row(s) after a role edit", n)
}
}
// `migrate` with no subcommand applies every registered migration, an
// application's included, so an application can have all of its rows and
// never have had a sys_app row. Refusing to clean that up would leave the
// only case where nothing else can.
func TestUninstallWorksWithoutASysAppRow(t *testing.T) {
db := newUninstallDB(t)
seedApp(t, db, "order")
if err := db.Where("app_code = ?", "order").Delete(&adminmodels.SysApp{}).Error; err != nil {
t.Fatalf("removing the sys_app row: %v", err)
}
rep, err := uninstall(db, "order")
if err != nil {
t.Fatalf("uninstall: %v", err)
}
if rep.Found {
t.Error("the report claims a sys_app row that was not there")
}
if n := count(t, db, "sys_menu", "app_code = ?", "order"); n != 0 {
t.Errorf("sys_menu still has %d row(s)", n)
}
var out strings.Builder
reportUninstall(&out, rep)
if !strings.Contains(out.String(), "no sys_app row") {
t.Errorf("the report does not say the row was missing: %q", out.String())
}
}
// One transaction, and it really is one: nothing here runs DDL, so unlike an
// install there is nothing to commit it out from under itself.
func TestUninstallRollsBackAsAWhole(t *testing.T) {
db := newUninstallDB(t)
seedApp(t, db, "order")
menusBefore := count(t, db, "sys_menu", "app_code = ?", "order")
policiesBefore := count(t, db, "casbin_rule", "", nil)
// Step 8's table is gone, so the uninstall fails after it has already
// deleted menus, apis, bindings and policies.
if err := db.Migrator().DropTable(&commonmodels.Migration{}); err != nil {
t.Fatalf("dropping sys_migration: %v", err)
}
if _, err := uninstall(db, "order"); err == nil {
t.Fatal("the uninstall reported success with sys_migration missing")
}
if n := count(t, db, "sys_menu", "app_code = ?", "order"); n != menusBefore {
t.Errorf("sys_menu = %d, want %d: the failed uninstall did not roll back", n, menusBefore)
}
if n := count(t, db, "casbin_rule", "", nil); n != policiesBefore {
t.Errorf("casbin_rule = %d, want %d: the failed uninstall did not roll back", n, policiesBefore)
}
}
func TestUninstallRefusesTheFrameworkCode(t *testing.T) {
db := newUninstallDB(t)
if _, err := uninstall(db, migration.FrameworkAppCode); err == nil {
t.Fatal("the framework was uninstalled")
}
}
// findOrphanPolicies chunks its OR chain because a driver runs out of
// placeholders long before an application runs out of endpoints. The
// boundary is where an off-by-one hides: a chunk size that drops the last
// element of each batch, or one that never advances, both leave policies
// unreported and nothing says so.
func TestFindOrphanPoliciesCoversEveryPathAcrossChunks(t *testing.T) {
db := newUninstallDB(t)
// Deliberately not a multiple of the chunk size, so the last batch is
// short, and large enough to need three of them.
const n = 205
keys := make([]policyKey, 0, n)
for i := 0; i < n; i++ {
path := "/api/v1/thing" + strconv.Itoa(i)
keys = append(keys, policyKey{V1: path, V2: "GET"})
if err := db.Exec(
"INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'ops', ?, 'GET', '', '', '')",
path,
).Error; err != nil {
t.Fatalf("seeding policy %d: %v", i, err)
}
}
// One policy that must not match: a path no key names.
if err := db.Exec(
"INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'ops', '/api/v1/elsewhere', 'GET', '', '', '')",
).Error; err != nil {
t.Fatalf("seeding the control policy: %v", err)
}
found, err := findOrphanPolicies(db, keys)
if err != nil {
t.Fatalf("findOrphanPolicies: %v", err)
}
if len(found) != n {
t.Fatalf("found %d policies, want %d", len(found), n)
}
seen := make(map[string]bool, len(found))
for _, f := range found {
seen[f.V1] = true
if f.V1 == "/api/v1/elsewhere" {
t.Error("a path no key names was reported")
}
}
for _, k := range keys {
if !seen[k.V1] {
t.Errorf("%s was not reported", k.V1)
}
}
}
// An application may register apis with no menus at all - endpoints another
// service calls - so either of the id lists an uninstall reads can be empty.
// The guard in front of the join-table delete turns out not to be what makes
// this work: GORM renders IN with an empty slice as a condition that matches
// nothing, rather than the empty IN list that would be a syntax error in raw
// SQL, and removing the guard leaves this test green. It stays as an explicit
// statement of intent rather than a reliance on that rendering.
func TestUninstallWithApisButNoMenus(t *testing.T) {
db := newUninstallDB(t)
apis := []seed.ApiSpec{
{Code: "hook", Title: "Inbound hook", Path: "/api/v1/hook", Method: "POST", Handle: "hook.Receive"},
}
if err := db.Transaction(func(tx *gorm.DB) error {
return seed.SeedMenus(tx, "hooks", nil, apis)
}); err != nil {
t.Fatalf("seeding: %v", err)
}
rep, err := uninstall(db, "hooks")
if err != nil {
t.Fatalf("uninstall: %v", err)
}
if rep.Apis != 1 {
t.Errorf("removed %d api(s), want 1", rep.Apis)
}
if rep.Policies != 1 {
t.Errorf("removed %d policy(ies), want 1", rep.Policies)
}
if n := count(t, db, "casbin_rule", "", nil); n != 0 {
t.Errorf("casbin_rule has %d row(s)", n)
}
}
// The mirror case: menus and no apis at all.
func TestUninstallWithMenusButNoApis(t *testing.T) {
db := newUninstallDB(t)
menus := []seed.MenuSpec{
{Code: "dir", Kind: "M", Title: "Reports", Path: "/apps/reports", Component: "Layout", Sort: 10},
}
if err := db.Transaction(func(tx *gorm.DB) error {
return seed.SeedMenus(tx, "reports", menus, nil)
}); err != nil {
t.Fatalf("seeding: %v", err)
}
rep, err := uninstall(db, "reports")
if err != nil {
t.Fatalf("uninstall: %v", err)
}
if rep.Menus != 1 {
t.Errorf("removed %d menu(s), want 1", rep.Menus)
}
if n := count(t, db, "sys_menu", "app_code = ?", "reports"); n != 0 {
t.Errorf("sys_menu has %d row(s)", n)
}
if len(rep.Orphans) != 0 {
t.Errorf("an application with no apis reported orphans: %v", rep.Orphans)
}
}
+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"},
}
+1 -1
View File
@@ -5,7 +5,7 @@ go 1.25.13
require (
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/go-admin-team/go-admin-core/v2 v2.5.0
github.com/go-admin-team/go-admin-core/v2 v2.8.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.5.0 h1:aD1SALklBxizGB9u8cOgm4OT8z656FM83F4fD6dMz9g=
github.com/go-admin-team/go-admin-core/v2 v2.5.0/go.mod h1:LG/XvEfOplbuadKrPTPm0Nu5pN06aQUNZZC3ao4B4gs=
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-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=
+39
View File
@@ -0,0 +1,39 @@
package migration
import (
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/app"
)
// Version is what this application calls itself. A host's installer records
// it, compares it against what is already installed to tell an upgrade from a
// downgrade, and shows it in `migrate status`.
//
// It is not the same thing as the migration version above, and the two move
// independently: adding a migration file without changing what the
// application is called is normal, and so is a release that changes no
// schema. The migration versions decide what runs; this decides what the
// installed row says.
const Version = "1.0.0"
// The manifest is registered from this package rather than one of its own
// because this is the package a host has to import for the application to
// exist at all - the migrations register here too. A second package would be
// a second thing to remember to import, and forgetting it would leave an
// application whose migrations run and which no installer can name.
func init() {
app.Register(app.Manifest{
Code: AppCode,
Name: "Order example",
Version: Version,
Description: "A worked example of an application that ships its own tables, menus and APIs.",
Author: "go-admin",
// Nothing yet. When an application does declare dependencies, a host
// refuses to install it until they are installed - it does not
// install them for you, because the blast radius of installing one
// application should not be "and everything it happens to name".
Requires: nil,
// Reserved. A host stores both and interprets neither.
Pricing: "",
License: "MIT",
})
}
-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>
+309
View File
@@ -0,0 +1,309 @@
package main
import (
"database/sql"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
_ "github.com/glebarez/go-sqlite"
)
// The whole 008 chain, against a binary that was actually built and a
// database that was actually migrated.
//
// Everything below this level is covered by unit tests with an injected
// engine and a hand-built schema, which is where the shapes are pinned down.
// What only this can catch is the wiring: that an application's init()
// reaches both registries, that `install` finds a manifest through
// app.Snapshot, that the seeder writes what the uninstaller looks for, and
// that the command exits non-zero when a migration fails - the last of which
// a deployment reads to decide whether to start the new version.
const settings = `settings:
application:
host: 0.0.0.0
mode: dev
name: e2e
port: 8000
readtimeout: 10000
writertimeout: 20000
database:
driver: sqlite3
source: ./e2e.db
jwt:
secret: e2e
timeout: 3600
logger:
path: temp/logs
stdout: default
level: error
enableddb: false
queue:
memory:
poolSize: 10
`
type env struct {
t *testing.T
dir string
bin string
}
// The binary is built once for the whole package. Every test drives the same
// one against its own directory and its own database, and a binary is
// read-only, so there is nothing to isolate - building it per test was three
// links of the same thing.
var (
buildOnce sync.Once
sharedDir string
sharedBin string
buildErr error
)
func TestMain(m *testing.M) {
code := m.Run()
if sharedDir != "" {
os.RemoveAll(sharedDir)
}
os.Exit(code)
}
// binary builds the go-admin binary with the example application linked in,
// on the first call that needs it.
func binary(t *testing.T) string {
t.Helper()
buildOnce.Do(func() {
sharedDir, buildErr = os.MkdirTemp("", "go-admin-e2e")
if buildErr != nil {
return
}
sharedBin = filepath.Join(sharedDir, "go-admin-e2e")
out, err := exec.Command("go", "build", "-tags", "sqlite3", "-o", sharedBin, ".").CombinedOutput()
if err != nil {
buildErr = fmt.Errorf("building the binary: %v\n%s", err, out)
}
})
if buildErr != nil {
t.Fatal(buildErr)
}
return sharedBin
}
// newEnv lays out a working directory for the binary to run in.
func newEnv(t *testing.T) *env {
t.Helper()
if testing.Short() {
t.Skip("builds a binary and migrates a database; skipped under -short")
}
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "config"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "temp", "logs"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "config", "settings.yml"), []byte(settings), 0o644); err != nil {
t.Fatal(err)
}
// The framework's first migration reads this file rather than carrying
// the rows in Go.
seedSQL, err := os.ReadFile(filepath.Join("..", "..", "config", "db.sql"))
if err != nil {
t.Fatalf("reading config/db.sql: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "config", "db.sql"), seedSQL, 0o644); err != nil {
t.Fatal(err)
}
return &env{t: t, dir: dir, bin: binary(t)}
}
// run executes the binary and returns its combined output and exit code.
func (e *env) run(args ...string) (string, int) {
e.t.Helper()
args = append(args, "-c", "config/settings.yml")
cmd := exec.Command(e.bin, args...)
cmd.Dir = e.dir
out, err := cmd.CombinedOutput()
code := 0
if ee, ok := err.(*exec.ExitError); ok {
code = ee.ExitCode()
} else if err != nil {
e.t.Fatalf("running %v: %v", args, err)
}
return string(out), code
}
func (e *env) mustRun(args ...string) string {
e.t.Helper()
out, code := e.run(args...)
if code != 0 {
e.t.Fatalf("%v exited %d:\n%s", args, code, out)
}
return out
}
// open connects to the database the binary uses.
//
// Per call, and closed again straight away, on purpose: the binary under test
// writes this same file, and a connection the test process holds open across
// a run of it is a second writer for no reason. The cost is a few
// milliseconds against a build measured in seconds.
func (e *env) open() *sql.DB {
e.t.Helper()
db, err := sql.Open("sqlite", filepath.Join(e.dir, "e2e.db"))
if err != nil {
e.t.Fatal(err)
}
return db
}
// exec runs one statement against the database the binary uses.
func (e *env) exec(stmt string) {
e.t.Helper()
db := e.open()
defer db.Close()
if _, err := db.Exec(stmt); err != nil {
e.t.Fatalf("%s: %v", stmt, err)
}
}
func (e *env) count(query string, args ...any) int {
e.t.Helper()
db := e.open()
defer db.Close()
var n int
if err := db.QueryRow(query, args...).Scan(&n); err != nil {
e.t.Fatalf("%s: %v", query, err)
}
return n
}
// seeded is what installing this application writes, with the counts a
// finished install leaves behind. An uninstall wants every one of them at
// zero, and a reinstall wants them back - which is why one list serves all
// three checks instead of three lists drifting apart.
var seeded = []struct {
what string
query string
want int
}{
{"menus", "SELECT COUNT(*) FROM sys_menu WHERE app_code = 'order'", 4},
{"apis", "SELECT COUNT(*) FROM sys_api WHERE app_code = 'order'", 4},
{"ledger", "SELECT COUNT(*) FROM sys_app_casbin_grant WHERE app_code = 'order'", 4},
{"policies", "SELECT COUNT(*) FROM casbin_rule WHERE v1 LIKE '/api/v1/order%'", 4},
{"migration records", "SELECT COUNT(*) FROM sys_migration WHERE app_code = 'order'", 1},
{"sys_app rows", "SELECT COUNT(*) FROM sys_app WHERE app_code = 'order'", 1},
}
// assertSeeded checks every row of seeded. gone flips the expectation to
// zero, which is the whole of what an uninstall has to leave.
func (e *env) assertSeeded(when string, gone bool) {
e.t.Helper()
for _, c := range seeded {
want := c.want
if gone {
want = 0
}
if n := e.count(c.query); n != want {
e.t.Errorf("%s, %s = %d, want %d", when, c.what, n, want)
}
}
}
func TestInstallUninstallReinstall(t *testing.T) {
e := newEnv(t)
// Framework only. The application's migration must not run here, or the
// install below has nothing left to do and the interesting half of it
// goes untested - which is what happens if this uses plain `migrate`.
e.mustRun("migrate", "--app", "core")
if n := e.count("SELECT COUNT(*) FROM sys_migration WHERE app_code = 'order'"); n != 0 {
t.Fatalf("the application's migration ran during the framework's: %d rows", n)
}
if n := e.count("SELECT COUNT(*) FROM sqlite_master WHERE name = 'app_order'"); n != 0 {
t.Fatal("the application's own table exists before it was installed")
}
// A1.
out := e.mustRun("migrate", "install", "order")
if !strings.Contains(out, "order-1793800000000") {
t.Errorf("the install did not report applying the migration:\n%s", out)
}
// A7.
if !strings.Contains(out, "rebuild") {
t.Errorf("the install did not say the code is not running yet:\n%s", out)
}
e.assertSeeded("after install", false)
if n := e.count("SELECT COUNT(*) FROM sqlite_master WHERE name = 'app_order'"); n != 1 {
t.Error("the application's own table was not created")
}
if n := e.count("SELECT COUNT(*) FROM sys_app WHERE app_code = 'order' AND status = 2"); n != 1 {
t.Error("sys_app does not say the install finished")
}
// A2: installing the same version again does nothing and says so.
out = e.mustRun("migrate", "install", "order")
if !strings.Contains(out, "already installed") {
t.Errorf("a second install was not reported as a no-op:\n%s", out)
}
// A3: the application's own data is not the uninstaller's to remove.
e.exec("INSERT INTO app_order (created_at, updated_at) VALUES (datetime('now'), datetime('now'))")
if n := e.count("SELECT COUNT(*) FROM app_order"); n != 1 {
t.Fatalf("the business row was not written: %d", n)
}
out = e.mustRun("migrate", "uninstall", "order")
if !strings.Contains(out, "own tables were not touched") {
t.Errorf("the uninstall did not say what it left alone:\n%s", out)
}
e.assertSeeded("after uninstall", true)
if n := e.count("SELECT COUNT(*) FROM sqlite_master WHERE name = 'app_order'"); n != 1 {
t.Error("the uninstall dropped the application's own table")
}
if n := e.count("SELECT COUNT(*) FROM app_order"); n != 1 {
t.Errorf("the uninstall removed %d business row(s)", 1-n)
}
// A4: the migration records had to go, or this reinstall finds every
// version applied, seeds nothing, and reports success.
e.mustRun("migrate", "install", "order")
e.assertSeeded("after reinstall", false)
if n := e.count("SELECT COUNT(*) FROM app_order"); n != 1 {
t.Error("the business row did not survive an uninstall and reinstall")
}
}
// A deployment decides whether to start the new version on this exit code.
func TestMigrateExitsNonZeroWhenItFails(t *testing.T) {
e := newEnv(t)
// The framework's first migration reads config/db.sql. Without it the
// migration fails, which is the cheapest real failure to arrange.
if err := os.Remove(filepath.Join(e.dir, "config", "db.sql")); err != nil {
t.Fatal(err)
}
out, code := e.run("migrate")
if code == 0 {
t.Errorf("a failed migration exited 0:\n%s", out)
}
}
func TestUnknownAppIsRefused(t *testing.T) {
e := newEnv(t)
e.mustRun("migrate", "--app", "core")
out, code := e.run("migrate", "install", "ordr")
if code == 0 {
t.Errorf("a mistyped code was installed:\n%s", out)
}
if !strings.Contains(out, "order") {
t.Errorf("the refusal does not name what is registered:\n%s", out)
}
}
+156
View File
@@ -0,0 +1,156 @@
module go-admin-e2e-apporder
go 1.26.5
require (
github.com/glebarez/go-sqlite v1.22.0
github.com/go-admin-team/example-app-order v0.0.0
go-admin v0.0.0
)
require (
dario.cat/mergo v1.0.2 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 // indirect
github.com/alibaba/sentinel-golang v1.0.4 // indirect
github.com/alibaba/sentinel-golang/pkg/adapters/gin v0.0.0-20241224061304-f4c2c5964666 // indirect
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect
github.com/andeya/ameda v1.5.3 // indirect
github.com/andeya/goutil v1.1.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bitly/go-simplejson v0.5.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/bytedance/go-tagexpr/v2 v2.9.11 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.2 // indirect
github.com/bytedance/sonic/loader v0.5.2 // indirect
github.com/casbin/casbin/v3 v3.8.1 // indirect
github.com/casbin/gorm-adapter/v3 v3.41.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99 // indirect
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
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-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
github.com/go-openapi/spec v0.22.9 // indirect
github.com/go-openapi/swag/conv v0.28.0 // indirect
github.com/go-openapi/swag/jsonutils v0.28.0 // indirect
github.com/go-openapi/swag/loading v0.28.0 // indirect
github.com/go-openapi/swag/pools v0.28.0 // indirect
github.com/go-openapi/swag/stringutils v0.28.0 // indirect
github.com/go-openapi/swag/typeutils v0.28.0 // indirect
github.com/go-openapi/swag/yamlutils v0.28.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.3 // indirect
github.com/go-sql-driver/mysql v1.10.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gofrs/flock v0.8.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.10.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-sqlite3 v1.14.49 // indirect
github.com/microsoft/go-mssqldb v1.10.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mojocn/base64Captcha v1.3.8 // indirect
github.com/mssola/user_agent v0.6.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nyaruka/phonenumbers v1.2.2 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/prometheus/client_golang v1.24.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/qiniu/go-sdk/v7 v7.27.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.61.0 // indirect
github.com/redis/go-redis/v9 v9.22.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/shamsher31/goimgext v1.0.0 // indirect
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/swaggo/gin-swagger v1.6.1 // indirect
github.com/swaggo/swag v1.16.6 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.2 // indirect
github.com/unrolled/secure v1.17.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/arch v0.30.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/image v0.41.0 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.48.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gorm.io/driver/mysql v1.6.0 // indirect
gorm.io/driver/postgres v1.6.2 // indirect
gorm.io/driver/sqlite v1.6.0 // indirect
gorm.io/driver/sqlserver v1.6.4 // indirect
gorm.io/gorm v1.31.2 // indirect
gorm.io/plugin/dbresolver v1.6.2 // indirect
gorm.io/plugin/soft_delete v1.2.1 // indirect
modernc.org/fileutil v1.3.40 // indirect
modernc.org/libc v1.67.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.42.2 // indirect
)
replace go-admin => ../..
replace github.com/go-admin-team/example-app-order => ../../example/app-order
+883
View File
@@ -0,0 +1,883 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg=
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 h1:7dONQ3WNZ1zy960TmkxJPuwoolZwL7xKtpcM04MBnt4=
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82/go.mod h1:nLnM0KdK1CmygvjpDUO6m1TjSsiQtL61juhNsvV/JVI=
github.com/alibaba/sentinel-golang v1.0.2/go.mod h1:QsB99f/z35D2AiMrAWwgWE85kDTkBUIkcmPrRt+61NI=
github.com/alibaba/sentinel-golang v1.0.4 h1:i0wtMvNVdy7vM4DdzYrlC4r/Mpk1OKUUBurKKkWhEo8=
github.com/alibaba/sentinel-golang v1.0.4/go.mod h1:Lag5rIYyJiPOylK8Kku2P+a23gdKMMqzQS7wTnjWEpk=
github.com/alibaba/sentinel-golang/pkg/adapters/gin v0.0.0-20241224061304-f4c2c5964666 h1:nLA94XbUtqArHLPtBZuPuIlOOS1rWnWs8ANnMj+TW4Y=
github.com/alibaba/sentinel-golang/pkg/adapters/gin v0.0.0-20241224061304-f4c2c5964666/go.mod h1:RWwrQy9bLKVZyr47l34uImkq7LQEc2ngkKn2O2LV1gE=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
github.com/andeya/ameda v1.5.3 h1:SvqnhQPZwwabS8HQTRGfJwWPl2w9ZIPInHAw9aE1Wlk=
github.com/andeya/ameda v1.5.3/go.mod h1:FQDHRe1I995v6GG+8aJ7UIUToEmbdTJn/U26NCPIgXQ=
github.com/andeya/goutil v1.0.1/go.mod h1:jEG5/QnnhG7yGxwFUX6Q+JGMif7sjdHmmNVjn7nhJDo=
github.com/andeya/goutil v1.1.2 h1:RiFWFkL/9yXh2SjQkNWOHqErU1x+RauHmeR23eNUzSg=
github.com/andeya/goutil v1.1.2/go.mod h1:jEG5/QnnhG7yGxwFUX6Q+JGMif7sjdHmmNVjn7nhJDo=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=
github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/go-tagexpr/v2 v2.9.11 h1:jJgmoDKPKacGl0llPYbYL/+/2N+Ng0vV0ipbnVssXHY=
github.com/bytedance/go-tagexpr/v2 v2.9.11/go.mod h1:UAyKh4ZRLBPGsyTRFZoPqTni1TlojMdOJXQnEIPCX84=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
github.com/casbin/casbin/v3 v3.8.1 h1:D4dEY4knePPR4YgNP5WZtWNaOxD0UK0LpPy9+zxtBwo=
github.com/casbin/casbin/v3 v3.8.1/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8=
github.com/casbin/gorm-adapter/v3 v3.41.0 h1:Xhpi0tfRP9aKPDWDf6dgBxHZ9UM6IophxxPIEGWqCNM=
github.com/casbin/gorm-adapter/v3 v3.41.0/go.mod h1:BQZRJhwUnwMpI+pT2m7/cUJwXxrHfzpBpPcNTyMGeGA=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99 h1:K62Lb6bsgLOB++z/VAvRvtiEBdNCuMfmQGTGGWMdPpM=
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99/go.mod h1:9+sJ9zvvkXC5sPjPEZM3Jpb9n2Q2VtcrGZly0UHYF5I=
github.com/chanxuehong/util v0.0.0-20200304121633-ca8141845b13/go.mod h1:XEYt99iTxMqkv+gW85JX/DdUINHUe43Sbe5AtqSaDAQ=
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd h1:v3JNsFZmplLO/Cmiyr/rGvR7lW1ld9lB+d5h4yR0MTI=
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd/go.mod h1:mysjrtCs9MmN8hqDf4/mc4eQ26Rt9s1p5oO+fhJlLB4=
github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.7.0/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-admin-team/go-admin-core/v2 v2.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-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=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM=
github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w=
github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8=
github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k=
github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4=
github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU=
github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k=
github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU=
github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU=
github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY=
github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY=
github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
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.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE=
github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible h1:lX3m9hvP5tSnJ8bFg/TdT2BYHj1nSBulealy5VN9mPU=
github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible/go.mod h1:l7VUhRbTKCzdOacdT4oWCwATKyvZqUOlOqr0Ous3k4s=
github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM=
github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY=
github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mojocn/base64Captcha v1.3.8 h1:rrN9BhCwXKS8ht1e21kvR3iTaMgf4qPC9sRoV52bqEg=
github.com/mojocn/base64Captcha v1.3.8/go.mod h1:QFZy927L8HVP3+VV5z2b1EAEiv1KxVJKZbAucVgLUy4=
github.com/mssola/user_agent v0.6.0 h1:uwPR4rtWlCHRFyyP9u2KOV0u8iQXmS7Z7feTrstQwk4=
github.com/mssola/user_agent v0.6.0/go.mod h1:TTPno8LPY3wAIEKRpAtkdMT0f8SE24pLRGPahjCH4uw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU=
github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k=
github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w=
github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nyaruka/phonenumbers v1.0.55/go.mod h1:sDaTZ/KPX5f8qyV9qN+hIm+4ZBARJrupC6LuhshJq1U=
github.com/nyaruka/phonenumbers v1.2.2 h1:OwVjf7Y4uHoK9VJUrA8ebR0ha2yc6sEYbfrwkq0asCY=
github.com/nyaruka/phonenumbers v1.2.2/go.mod h1:wzk2qq7qwsaBKrfbkWKdgHYOOH+QFTesSpIq53ELw8M=
github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis=
github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA=
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=
github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.9.0/go.mod h1:FqZLKOZnGdFAhOK4nqGHa7D66IdsO+O441Eve7ptJDU=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.15.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/qiniu/go-sdk/v7 v7.27.0 h1:n+2U0S5fhbmG/lN/agO8KcYJaQDqROUVShtnp56Mkw8=
github.com/qiniu/go-sdk/v7 v7.27.0/go.mod h1:pTwVR1B+8SXcPLhDzBUasiKFTD9F7jRglRDR553BW3k=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA=
github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/shamsher31/goimgext v1.0.0 h1:wFKf9GeeE0Xr6UQtliaPgYYgTju2izobM7XpCEgUCC8=
github.com/shamsher31/goimgext v1.0.0/go.mod h1:rYLKgXuTGBIaH49z+jUVSWz7gUWIZmqvYUsdvJbNNOc=
github.com/shirou/gopsutil v3.20.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/shirou/gopsutil/v3 v3.21.6/go.mod h1:JfVbDpIBLVzT8oKbvMg9P3wEIMDDpVn+LwHTKj0ST88=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tklauser/go-sysconf v0.3.6/go.mod h1:MkWzOF4RMCshBAMXuhXJs64Rte09mITnppBXY/rYEFI=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.2.2/go.mod h1:x3qojaO3uyYt0i56EW/VUYs7uBvdl2fkfZFu0T9wgjM=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU=
golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210316164454-77fc1eacc6aa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4=
gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/driver/sqlserver v1.6.4 h1:kGA9Z0D7dnIz7yVvWp18qLBSPFpUQWGqMA4rnxkScdQ=
gorm.io/driver/sqlserver v1.6.4/go.mod h1:oRtXDKFRYj8MqyMq+JFEdaA+StSQKC4zupU6blIdB0s=
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.23.0/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
gorm.io/plugin/soft_delete v1.2.1 h1:qx9D/c4Xu6w5KT8LviX8DgLcB9hkKl6JC9f44Tj7cGU=
gorm.io/plugin/soft_delete v1.2.1/go.mod h1:Zv7vQctOJTGOsJ/bWgrN1n3od0GBAZgnLjEx+cApLGk=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
+28
View File
@@ -0,0 +1,28 @@
// Command e2e-apporder is a go-admin binary with the example application
// linked into it.
//
// It exists because there is otherwise nothing to install. `migrate install
// order` looks the code up in the registries an application fills from its
// own init(), and an application the binary was not built with registers
// nothing - so every path through the installer past its first check went
// untested, and so did the seeding, the ledger and the uninstall against
// anything but a hand-built fixture.
//
// This lives in its own module, not behind a build tag in the main one. A
// tagged import there would still be resolved by `go mod tidy`, which
// considers every build tag and would go looking for
// github.com/go-admin-team/example-app-order on the network - a repository
// that does not exist, because the example is a directory inside this one.
// A separate module with replace directives is invisible to the main
// module's tidy, its build and its tests, and needs no go.work.
package main
import (
_ "github.com/go-admin-team/example-app-order/migration"
"go-admin/cmd"
)
func main() {
cmd.Execute()
}