Commit Graph
1807 Commits
Author SHA1 Message Date
zhangwenjian eb4e0f21df docs📝: teach the generic actions instead of Generate()
AGENTS.md, docs/contract.md and the new-business-module skill showed
the older actions and made returning a copy from Generate() a rule to
remember. They now show the generic actions, whose type parameters are
the rule, and say the Generate() rule holds only for modules still on
the deprecated actions.
2026-09-27 20:46:21 +08:00
zhangwenjian 4d17146a25 chore🔧: deprecate the five CRUD actions the generic ones replace
IndexAction, ViewAction, CreateAction, UpdateAction and DeleteAction
stay and behave as before; each now points at its generic replacement.
Nothing in this repository calls them any more.
2026-09-27 20:46:21 +08:00
zhangwenjian fda94c4965 refactor🎨: move app/jobs onto the generic actions
The five /sysjob routes use the generic actions. The detail route uses
ViewAs to keep answering SysJobItem, whose entryId the edit form reads
where the model marshals entry_id. GenerateM becomes ToModel, and the
Generate methods go.

Recorded before the change and replayed after it, the same 14 requests
get byte-identical responses; answering the detail with the model
instead of SysJobItem makes exactly the two detail requests differ.

With a real ToModel on both modules, a test now builds a package that
pairs the demo model with the jobs request and requires the build to
fail on that pairing, next to the same package with the pair corrected.
2026-09-27 20:46:12 +08:00
zhangwenjian f7c223e0dc refactor🎨: move app/demo onto the generic actions
The five routes use actions.Index, View, Create, Update and Delete. The
control request's GenerateM becomes ToModel, returning the model type
itself, and the Generate methods on the model and the three request
types go, along with the tests that held them to returning copies:
nothing calls them any more.

Recorded before the change and replayed after it, the same 18 requests
- creates, lists with filters and paging, details, updates and deletes,
found and missing - get byte-identical responses.
2026-09-27 20:46:12 +08:00
zhangwenjian 024dd047ae feat✨: add generic CRUD actions
Index, View, ViewAs, Create, Update and Delete take the model and the
request as type parameters. They serve the same routes as IndexAction
and its siblings and answer them the same way; what they drop is what
the caller had to get right.

The older actions serve every request from instances passed at
registration, so each model and request type needs a Generate that
returns a copy, and a list route takes a func() interface{} whose
element type nothing checks. Here every request declares its own
values, and a model paired with the wrong request does not compile.
ViewAs answers a detail route with a type other than the model, as
ViewAction's f did.

Beyond that they differ in three places, all where the older ones were
wrong: a request with no database is answered with 500 instead of an
empty 200; the key is matched against the primary-key column as a
value, not handed to Where, which reads a string as SQL; and there is
no GenerateM whose error could be dropped.

A test runs the old and new actions on the same types through one
request script and requires every response to match.
2026-09-27 20:46:01 +08:00
zhangwenjian ea2d807071 fix🐛: give queries the request's context, not the pooled gin.Context
WithContextDb and the five CRUD actions passed the *gin.Context itself
to GORM as the query's context. database/sql watches the context of a
query that returns rows inside a transaction from a goroutine of its
own, which can still read it after the handler returns - and gin hands
that Context to the next request as soon as the handler returns. GORM
wraps every write in a transaction, and an INSERT on SQLite or
PostgreSQL returns the new key as a row, so any write raced with the
next request's reset. The race detector reports it on the second of
two sequential requests.

They now pass c.Request.Context(), which belongs to one request only
and is cancelled when its client goes away.
2026-09-27 20:45:45 +08:00
wenjianzhang f8cbb3a5b3 Merge pull request #955 from go-admin-team/fix/gen-path-and-authz
fix🐛: confine the code generator's writes and check roles on its routes
2026-09-27 19:56:29 +08:00
zhangwenjian e347854919 fix🐛: check the role on the code generator's routes
Every generator route was reachable by any account that could log in:
the /gen and /db routes were in CasbinExclude, and the /sys/tables
routes were mounted without AuthCheckRole. Any user could read and
change any table's configuration and, in dev mode, generate files
from it.

All thirteen now go through AuthCheckRole. admin is let through as
before; another role needs the generator's menus, whose APIs the
previous commit binds and grants. The captcha, registered alongside
them, stays public.
2026-09-27 19:43:32 +08:00
zhangwenjian 8aa5a0cd9a fix🐛: bind the code generator's APIs to its menus
A role is granted an API through its menus: saving a role writes a
casbin_rule for every API bound to each menu it holds. The seed data
bound none to 代码生成 or 代码生成修改, so a role given either menu
received no API, which went unnoticed only because every generator
route skipped the role check.

Migration 1786700011000 binds the nine APIs the generator page calls
and the four its edit page calls, creating any that sys_api lacks, and
grants them to each role that already holds the menu. Menus are found
by component, so renumbered ones are found and deleted ones skipped.
Tested on SQLite, MySQL and PostgreSQL, with SQL Server in CI.
2026-09-27 19:43:19 +08:00
zhangwenjian c0ca07c95b fix🐛: refuse generator names that cannot be path components
The package, table and business names a table's configuration carries
become directories and file names, and nothing on the server checked
them; the config page's patterns were the only check.

Each is now checked against what it becomes: a package name is
lowercase letters and digits, a table name letters, digits and
underscores, a business name an identifier starting with a lowercase
letter. None allows a dot or a separator, and none is stricter than
the config page. The check runs on import, where a list holding one
bad name is refused whole, on save, and again before generating, so a
configuration saved before this check existed is refused rather than
trusted.
2026-09-27 19:43:04 +08:00
zhangwenjian afa8513d4b fix🐛: keep the code generator's writes inside their roots
The generator joins a table's package, table and business names into
the paths it writes, and wrote them with os.MkdirAll and os.WriteFile.
A name carrying "..", or a symlink under the target directory pointing
elsewhere, took the write wherever it led.

Every file is now written through os.Root: backend files under the
working directory, frontend files under gen.frontpath. A path that
resolves outside its root is refused, symlinks included, and the
request reports the write failure without writing anything outside.
2026-09-27 19:42:35 +08:00
wenjianzhang c1d92ad30f Merge pull request #954 from go-admin-team/fix/gen-audit-columns-and-empty-form
fix🐛: generator config for audit timestamps, and no empty form
v2.7.0
2026-09-27 16:16:18 +08:00
zhangwenjian 2642d0959d fix🐛: leave the form out of a generated page with nothing to enter
A table whose columns are all its key and ones the framework fills in
got an add button and an edit link opening an empty form. The page now
leaves out both, the dialog, useForm and the API calls only the form
used. A page with anything to enter is generated byte for byte as
before.
2026-09-27 16:07:10 +08:00
zhangwenjian 8cbbd6eddd feat✨: show created_at and updated_at on the generator's config page
The config page hid both columns along with the ones the framework
fills in, so a generated page could not list or filter by when a row
was created or last changed without editing sys_columns by hand. They
are now shown; id, create_by, update_by and deleted_at stay hidden.
The templates already keep both out of the create/edit form.

The test imports a table, ticks list and query on created_at through
the config page's own endpoints, generates it, and filters the
generated list with the value the generated date picker sends.
2026-09-27 16:07:10 +08:00
zhangwenjian de33e527fc fix🐛: treat a column saved with isQuery "0" as not queryable
The config page saves an unticked query box as "0", and the DTO and API
templates tested IsQuery with a bare if, which is true for any
non-empty string. A column ticked and then unticked became a filter of
the generated list request and a documented query parameter anyway.
They now require "1", as the TypeScript and Vue templates already do.
2026-09-27 16:07:09 +08:00
zhangwenjian d30b9c103f fix🐛: import "time" in a generated model only for fields it declares
The model template imported "time" for any queryable time.Time column,
though whether a column is queryable only shapes the DTO. A queryable
created_at, which the model takes from models.ModelTime rather than
declaring, left the import unused and the generated package failed to
compile.
2026-09-27 16:07:09 +08:00
zhangwenjian 77fddfa356 test✅: hard-delete the generator fixtures, as the delete endpoint does
dropFixture soft-deleted the sys_tables rows it cleaned up. The config
page's business-name check counts soft-deleted rows, so a second test
importing the same table could not save its configuration. The delete
endpoint removes these rows with Unscoped; the fixture now does too.
2026-09-27 16:07:07 +08:00
wenjianzhang 79cc8eba34 Merge pull request #953 from go-admin-team/fix/check-file-create-errors
fix🐛: report failures from app and migrate -g file generation
2026-09-27 14:07:56 +08:00
zhangwenjian 6d06241e0d fix🐛: report failures from app and migrate -g file generation
Both commands discarded genFile's error, and genFile discarded the
errors from rendering its templates and from pkg.FileCreate. A file
that could not be written ended the process from inside FileCreate;
with core v2.11.0 it would have been reported as success instead.

genFile now returns every error, and both commands print it and exit
non-zero. In `app`, a missing router template no longer panics on a
nil template, and neither file is written unless both render.
2026-09-27 14:01:48 +08:00
zhangwenjian d89e0cd8eb chore⬆️: upgrade go-admin-core to v2.11.0
v2.11.0 makes pkg.FileCreate return its error instead of ending the
process when the file cannot be created. Two comments in the code
generator described the old behaviour in the present tense.
2026-09-27 14:01:48 +08:00
wenjianzhang c055dc68c5 Merge pull request #945 from amiaogo/fix/pg-config-sequence
fix🐛: correct sequence name for sys_config in pg.sql
2026-09-26 14:40:27 +08:00
wenjianzhang a60a6438fe Merge pull request #944 from amiaogo/fix/opera-log-utf8-truncate
fix🐛: truncate opera log jsonResult by rune to keep valid UTF-8
2026-09-26 14:32:22 +08:00
zhangwenjian b6595fb076 fix🐛: create the job sequence under the name the calibration uses
The setval below now targets sys_job_job_id_seq, the sequence job_id
actually uses, but the create above it still named sys_job_id_seq, so a
fresh PostgreSQL install was left with an unused sequence of that name.
2026-09-26 14:31:36 +08:00
wenjianzhang f8fa20f4e7 Merge pull request #952 from go-admin-team/fix/gen-swallowed-errors
Code generator: stop swallowing errors when saving and generating
2026-09-26 14:31:28 +08:00
zhangwenjian d84e30afb8 fix🐛: report a table the generator cannot read
Preview, GenCode, GenApiToFile and GenMenuAndApi read a table's
configuration with tab, _ := table.Get(...), so an id that matched nothing,
or a failed query, carried on with an empty table. GenCode and Preview then
refused it as having no primary key, and GenMenuAndApi went on to seed menus
and APIs for it.

Each now answers with the lookup's error.
2026-09-26 14:17:22 +08:00
zhangwenjian 83064f0b37 fix🐛: render every generated file before writing any, and check both
Preview, NOActionsGen and genApiToFile assigned each template's Execute
error to err and never read it - 19 times - so a template failing part-way
left a truncated file behind it and the request reported success. The
files were written through pkg.FileCreate, which returns no error at all:
when it cannot create a file it closes a nil *os.File and calls
log.Fatalln, ending the server process. Four of the directories it writes
into were created with _ = pkg.PathCreate(...).

Every template now renders into memory first; if any fails, nothing is
written and the response names the template. Files are then written
through writeGenerated, which creates the directory and returns any error
instead of exiting. NOActionsGen and genApiToFile report whether they
succeeded, so GenCode and GenApiToFile no longer follow an error response
with "Code generated successfully".
2026-09-26 14:17:22 +08:00
zhangwenjian 88b14a5e0f fix🐛: write a table's configuration and its columns in one transaction
SysTables.Create and Update wrote each column with _, _ = …, so a column
the database rejected was dropped while the request reported success. And
because neither ran in a transaction, returning that error alone would not
have been enough: the table row and every column before the failing one
were already committed, leaving a table the config page lists with columns
missing and code generated from fewer columns than the table has.

Both now run the table and its columns in one transaction and return the
first column's error, named. Create rolls back to nothing; Update leaves the
table and every column as they were. Both callers already answer an error
with a 500.
2026-09-26 14:17:19 +08:00
wenjianzhang 827ad1146e Merge pull request #951 from go-admin-team/feat/gen-string-key-form
Code generator: a string primary key can be created from the generated page
2026-09-25 21:20:28 +08:00
zhangwenjian 6e8b1891bb feat✨: let a generated page create a record keyed on a string
The generated form never showed the key column, which is right for a key
the database assigns and left a string key with nowhere to be typed: a
table keyed on one could be created through the API but not from its page.

For a string key the form now carries an input for it, required, and
disabled once the record exists - bound to form.isEdit, the same way the
hand-written dictionary and config pages lock their natural keys. A page for
a table keyed on an int renders exactly as before.

This needs go-admin-ui's useForm to record whether a form was opened to
create or to edit (go-admin-ui#310). The useForm before it inferred the mode
from the key's presence, so typing the key into a create form turned the
submit into an update.
2026-09-25 20:21:40 +08:00
wenjianzhang 719d54cf0e Merge pull request #950 from go-admin-team/fix/gen-non-id-primary-key
Code generator: support any single-column primary key
2026-09-25 20:10:48 +08:00
zhangwenjian 0ff5f1a027 test✅: import and generate every primary-key shape, then drive the result
The generator had no test that compiled what it writes; test/gen_test.go is
commented out. This one goes through the real path end to end: fixture
tables in MySQL, the import handler, GenCode writing its files, the files
compiled into this module's own packages through a -overlay, and a CRUD
test run over HTTP against the generated handlers.

The shapes are an int key named id, an int key named otherwise, a string
key, no key and a composite key. The last two must be refused with nothing
written. For the rest: read and update through the path parameter, a
missing key neither found nor created by an update, a key of "1=1" read as
a value, and delete by key.

The importer only reads MySQL, so the test needs GO_ADMIN_TEST_MYSQL_DSN;
without it the test skips locally and fails under CI. It also asserts each
generated CRUD test actually ran, since a -run pattern that matches nothing
exits 0.
2026-09-25 20:00:40 +08:00
zhangwenjian 99d22249ef fix🐛: refuse to generate a table whose key is not one column
The templates address a row by a single key: one field on the model, one
path parameter, one value per row in a delete. A table with no primary key
rendered a model whose GetId returned an empty field name, and a composite
key was silently narrowed to whichever of its columns the importer read
last. Both wrote files that break the build of the whole module.

Preview and NOActionsGen now check first and return an error naming the
table and, for a composite key, its columns. Nothing is written.
2026-09-25 20:00:30 +08:00
zhangwenjian 3112896a83 fix🐛: stop a generated Update when its row is not found
Update looked its row up and ignored the result. When nothing matched -
because the key does not exist, or because the caller's data scope filters
the row out - the model stayed at its zero value, Generate copied the
request onto it, and Save, finding a zero key, inserted a new row. The
update of a row the caller may not see became a row they now own, and the
RowsAffected check meant to refuse it never fired, because the insert did
affect one.

A missing row now ends the update with the same "无权更新该数据" the
RowsAffected check returns, and any other lookup error is returned as is.
This applies to every generated table, an int id included.
2026-09-25 20:00:30 +08:00
zhangwenjian bf8a1b3374 fix🐛: bind the generated service's key as a query value
Get and Update found their row with First(&model, id). GORM reads a string
passed that way as a SQL condition rather than a key, so with a string
primary key the path parameter went into the WHERE clause as written: "c-1"
failed as the expression c - 1, and GET /…/1=1 returned a row.

Both now filter on clause.PrimaryColumn with the id as a bound value, which
GORM resolves to the model's own quoted key column whatever it is called.
Delete keeps Delete(&model, ids): a slice is always taken as key values.
2026-09-25 20:00:21 +08:00
zhangwenjian d53c8abc6c fix🐛: generate a model and DTOs for any single-column primary key
The model and DTO templates assumed every table's key is an int column named
id. For any other key the generated backend did not compile - the model
embedded models.Model, which supplies Id, skipped the real key column, and
GetId then returned a field that did not exist ("e.Code undefined"). Past the
compile error there was more of the same assumption:

  - GetReq and UpdateReq bound the key with uri:"<json field>", while every
    generated router registers it as :id, so any other name bound nothing;
  - InsertReq tagged the key json:"-", so a key the database does not assign,
    such as a string, could never be supplied;
  - DeleteReq took Ids []int, so a string key could not be deleted.

models.Model now stands in for the key only when the key is exactly an int
named Id; anything else is declared as its own primaryKey field. The key is
bound from :id, accepted from the request body when it is not an int, and
deleted by a list of its own type. A table keyed on an int id renders
byte-for-byte as before.
2026-09-25 19:59:59 +08:00
wenjianzhang c463f3696d Merge pull request #949 from go-admin-team/chore/core-v2.10.0
Take go-admin-core v2.10.0, and fix the log call it exposes
2026-09-24 20:48:20 +08:00
zhangwenjian 60db31e0e8 chore⬆️: go-admin-core v2.8.0 -> v2.10.0
Picks up two releases at once:

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

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

    find dept list error, %sconnection refused

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The DSN keeps loc=Local on purpose: it is what config/settings.yml ships. A
DSN here that quietly differed would test a configuration nobody runs.
2026-09-20 18:31:44 +08:00