Compare commits

..
Author SHA1 Message Date
zhangwenjian 58105cb478 docs📝(example): drop a stale ordering claim from the router test
The comment said the test had to be declared first because Go runs a
package's tests in source order. That is not a guarantee, and it is not what
makes this work: the test that registers RoleCheck puts it back in a
t.Cleanup, and the guard here turns a wrong order into a loud failure rather
than a silent pass. Verified with go test -shuffle on seeds that run the two
in either order.

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

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

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

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:53:45 +08:00
zhangwenjian 0729624c2f fix🐛(example): bring the directory menu's sort inside a tinyint
sys_menu.sort is `gorm:"size:4"`, which MySQL builds as a tinyint holding
-128..127. Sort: 200 passes every sqlite test - sqlite ignores the width -
and fails on a real install with Error 1264, partway through a migration.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

PRD 006 F3/F5.

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

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

PRD 006 F2/F5.

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

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

PRD 006 F1/F5.

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:23:01 +08:00
35 changed files with 2154 additions and 778 deletions
+26 -2
View File
@@ -1,6 +1,7 @@
package apis
import (
"errors"
"github.com/gin-gonic/gin/binding"
"go-admin/app/admin/models"
"golang.org/x/crypto/bcrypt"
@@ -15,6 +16,7 @@ import (
"go-admin/app/admin/service"
"go-admin/app/admin/service/dto"
"go-admin/common/actions"
"go-admin/common/middleware"
)
type SysUser struct {
@@ -149,12 +151,34 @@ func (e SysUser) Update(c *gin.Context) {
return
}
req.SetUpdateBy(user.GetUserId(c))
callerId := user.GetUserId(c)
// This route is in CasbinExclude so the personal-center screen can edit
// the caller's own record without a policy grant (see settings.go). That
// exclusion covers the whole route, not just the caller's own record, and
// the request carries the target userId in the body - so without this
// check here, any authenticated caller could edit any other user, up to
// and including their roleId. When the target is someone else, ask Casbin
// directly for the permission AuthCheckRole skipped.
if req.UserId != callerId {
allowed, err := middleware.EnforceRoleFor(c, c.Request.URL.Path, c.Request.Method)
if err != nil {
e.Logger.Error(err)
e.Error(500, err, err.Error())
return
}
if !allowed {
e.Error(http.StatusForbidden, errors.New("无权更新其他用户数据"), "对不起,您没有该接口访问权限,请联系管理员")
return
}
}
req.SetUpdateBy(callerId)
//数据权限检查
p := actions.GetPermissionFromContext(c)
err = s.Update(&req, p)
err = s.Update(&req, p, callerId)
if err != nil {
e.Logger.Error(err)
return
+175
View File
@@ -0,0 +1,175 @@
package apis
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
mycasbin "github.com/go-admin-team/go-admin-core/v2/casbin"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"gorm.io/gorm"
"go-admin/app/admin/models"
)
// PUT /api/v1/sys-user is in settings.go's CasbinExclude so the
// personal-center screen (go-admin-ui's userInfo.vue) can edit the caller's
// own record without holding a policy grant on this route. AuthCheckRole
// skips Enforce entirely for an excluded route, so this file's job is to pin
// what the handler itself now has to hold shut: the target userId comes from
// the request body, and nothing upstream of the handler ever checked it
// against the caller.
// setupPrivescDB wires an in-memory database and a Casbin enforcer with an
// empty policy - the state of a fresh install for any role but admin - under
// a tenant unique to the calling test, so mycasbin's process-wide enforcer
// cache can't hand one test's database to another.
func setupPrivescDB(t *testing.T) (*gorm.DB, string) {
t.Helper()
// Fatalf, not Skipf: this database is in-memory sqlite with no external
// dependency, so failing to open or migrate it means the environment is
// actually broken. Skipping here would let these two anti-privesc
// regression tests silently stop running while CI stays green - a
// standing assertion that never fires is worse than no assertion.
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("sqlite unavailable: %v", err)
}
if err := db.AutoMigrate(&models.SysUser{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
tenant := "sys-user-privesc-" + t.Name()
previousInterval := mycasbin.ReloadInterval
mycasbin.ReloadInterval = 0 // opt out of the background reload goroutine; the test never writes a policy
t.Cleanup(func() { mycasbin.ReloadInterval = previousInterval })
e := mycasbin.Setup(db, tenant)
previousEnforcer := sdk.Runtime.GetCasbinByTenant(tenant)
sdk.Runtime.SetCasbinByTenant(tenant, e)
t.Cleanup(func() { sdk.Runtime.SetCasbinByTenant(tenant, previousEnforcer) })
return db, tenant
}
// callUpdate drives SysUser.Update the way the router does for an
// authenticated, non-admin caller: JWT claims already decoded into the
// context (that is jwtauth's job, not this handler's) and a database - but
// without AuthCheckRole, since that middleware never runs Enforce for this
// route at all.
func callUpdate(t *testing.T, db *gorm.DB, tenant string, callerId int, body map[string]interface{}) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request body: %v", err)
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/sys-user", bytes.NewReader(raw))
c.Request.Host = tenant
c.Request.Header.Set("Content-Type", "application/json")
c.Set("db", db)
c.Set(pkg.LoggerKey, logger.NewHelper(logger.DefaultLogger))
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{
"identity": float64(callerId),
"rolekey": "ordinary-role", // holds no Casbin policy anywhere in this test
})
SysUser{}.Update(c)
return w
}
// TestUpdate_CannotEscalatePrivilegeThroughAnotherUsersRecord is the
// regression for H6. Before the fix, an ordinary authenticated user could PUT
// a body naming another user's id and change that user's roleId - the route
// being Casbin-excluded meant no permission check ever ran, and the data
// permission scope that would otherwise gate this is off by default.
func TestUpdate_CannotEscalatePrivilegeThroughAnotherUsersRecord(t *testing.T) {
db, tenant := setupPrivescDB(t)
victim := models.SysUser{Username: "bob", NickName: "Bob", RoleId: 2, DeptId: 1, Status: "1"}
if err := db.Create(&victim).Error; err != nil {
t.Fatal(err)
}
attacker := models.SysUser{Username: "alice", NickName: "Alice", RoleId: 2, DeptId: 1, Status: "1"}
if err := db.Create(&attacker).Error; err != nil {
t.Fatal(err)
}
const elevatedRoleId = 1 // a role the attacker does not hold and has no policy for
callUpdate(t, db, tenant, attacker.UserId, map[string]interface{}{
"userId": victim.UserId,
"username": victim.Username,
"nickName": "pwned",
"phone": "13800000000",
"email": "bob@example.com",
"roleId": elevatedRoleId,
"deptId": victim.DeptId,
"status": victim.Status,
})
var after models.SysUser
if err := db.First(&after, victim.UserId).Error; err != nil {
t.Fatal(err)
}
if after.RoleId == elevatedRoleId {
t.Fatalf("an attacker with no Casbin permission on this route escalated the victim's roleId to %d", after.RoleId)
}
if after.NickName == "pwned" {
t.Fatalf("an attacker with no Casbin permission on this route modified another user's record: %+v", after)
}
}
// TestUpdate_SelfEditCannotChangePrivilegedFields covers the case the
// CasbinExclude entry exists for: the personal-center screen has to keep
// working for the caller's own record. The fields that screen exposes
// (nickName/phone/email/sex) must still save, while roleId/deptId/status stay
// whatever the database already had even if the request carries something
// else - a compromised or hand-crafted client is the only way that request
// would ever differ from what the honest form sends.
func TestUpdate_SelfEditCannotChangePrivilegedFields(t *testing.T) {
db, tenant := setupPrivescDB(t)
self := models.SysUser{Username: "carol", NickName: "Carol", RoleId: 2, DeptId: 1, Status: "1"}
if err := db.Create(&self).Error; err != nil {
t.Fatal(err)
}
const elevatedRoleId = 1
callUpdate(t, db, tenant, self.UserId, map[string]interface{}{
"userId": self.UserId,
"username": self.Username,
"nickName": "Carol Updated",
"phone": "13900000000",
"email": "carol@example.com",
"roleId": elevatedRoleId, // tampered; must not take effect
"deptId": self.DeptId,
"status": self.Status,
})
var after models.SysUser
if err := db.First(&after, self.UserId).Error; err != nil {
t.Fatal(err)
}
if after.RoleId == elevatedRoleId {
t.Fatalf("a self-edit changed the caller's own roleId to %d", after.RoleId)
}
if after.NickName != "Carol Updated" {
t.Fatalf("the legitimate personal-center edit did not go through: %+v", after)
}
}
+15 -1
View File
@@ -84,7 +84,16 @@ func (e *SysUser) Insert(c *dto.SysUserInsertReq) error {
}
// Update 修改SysUser对象
func (e *SysUser) Update(c *dto.SysUserUpdateReq, p *actions.DataPermission) error {
//
// callerId is who is asking, not who SetUpdateBy recorded - that field only
// says who to blame, it never constrained who could be edited. When the
// target is the caller themselves, roleId/deptId/status are kept at whatever
// the database already has no matter what the request body carries: this is
// the personal-center screen's route (see CasbinExclude in settings.go, and
// the check in the API handler ahead of this call), and letting a caller
// grant themselves a different role or department through it would be a
// privilege escalation the exclusion was never meant to open.
func (e *SysUser) Update(c *dto.SysUserUpdateReq, p *actions.DataPermission, callerId int) error {
var err error
var model models.SysUser
db := e.Orm.Scopes(
@@ -98,6 +107,11 @@ func (e *SysUser) Update(c *dto.SysUserUpdateReq, p *actions.DataPermission) err
return errors.New("无权更新该数据")
}
if model.UserId == callerId {
c.RoleId = model.RoleId
c.DeptId = model.DeptId
c.Status = model.Status
}
c.Generate(&model)
update := e.Orm.Model(&model).Where("user_id = ?", &model.UserId).Omit("password", "salt").Updates(&model)
if err = update.Error; err != nil {
+135
View File
@@ -0,0 +1,135 @@
package actions_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"go-admin/common/actions"
"go-admin/common/dto"
"go-admin/common/models"
)
// capturingLogger records every SQL statement GORM actually executes, so a
// test can inspect it the way inspecting a *gorm.DB's own Statement cannot:
// IndexAction builds and executes its query in one unbroken chain
// (db.Model(...).Scopes(...).Find(...)...Count(...)) and never hands the
// built statement back to its caller.
type capturingLogger struct {
gormlogger.Interface
mu sync.Mutex
stmts []string
}
func (l *capturingLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
sql, _ := fc()
l.mu.Lock()
l.stmts = append(l.stmts, sql)
l.mu.Unlock()
}
func (l *capturingLogger) all() string {
l.mu.Lock()
defer l.mu.Unlock()
return strings.Join(l.stmts, "\n")
}
// probeRow is a minimal model satisfying models.ActiveRecord through the
// same embeds a real app/admin model uses, so IndexAction sees exactly the
// shape it is written against.
type probeRow struct {
models.Model
models.ControlBy
Name string
}
func (probeRow) TableName() string { return "action_probe_row" }
func (e *probeRow) Generate() models.ActiveRecord { o := *e; return &o }
func (e *probeRow) GetId() interface{} { return e.Id }
// probeIndexReq is a minimal dto.Index: no search tags, page defaults.
type probeIndexReq struct {
dto.Pagination `search:"-"`
}
// Generate returns a copy, the way every dto.Index in this repository does:
// IndexAction closes over one instance and serves every request to the route
// from it, so returning the receiver would share one struct across them. The
// probe has to model that faithfully or it is not the shape IndexAction is
// written against.
func (p *probeIndexReq) Generate() dto.Index { o := *p; return &o }
func (p *probeIndexReq) Bind(*gin.Context) error { return nil }
func (p *probeIndexReq) GetNeedSearch() interface{} { return *p }
type pageEnvelope struct {
Code int32 `json:"code"`
}
// TestIndexActionAppliesDataPermission is an end-to-end guard core's own
// test suite cannot provide. The five generic CRUD actions in this package
// (create/delete/index/update/view.go) were not lowered to core (PRD 006
// F3) - they still call actions.Permission directly, in this repository, on
// a code path core knows nothing about. core's tests pin down what
// Permission does for a given scope; nothing pinned down whether this
// package's own Actions still remember to call it at all. This runs
// IndexAction exactly as a real request would, against a real in-memory
// database, and inspects the SQL GORM actually executed - not just that
// the handler returned success, which it would just as happily do with no
// filter applied at all.
func TestProbeIndexReqGenerateReturnsAFreshInstance(t *testing.T) {
p := &probeIndexReq{}
got := p.Generate()
if got == dto.Index(p) {
t.Fatal("Generate returned the receiver; IndexAction would share one instance across every request to the route")
}
}
func TestIndexActionAppliesDataPermission(t *testing.T) {
previous := config.ApplicationConfig.EnableDP
config.ApplicationConfig.EnableDP = true
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
cl := &capturingLogger{Interface: gormlogger.Default.LogMode(gormlogger.Silent)}
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: cl})
if err != nil {
t.Fatalf("open: %v", err)
}
if err := db.AutoMigrate(&probeRow{}); err != nil {
t.Fatalf("AutoMigrate: %v", err)
}
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
c.Set("db", db)
c.Set(actions.PermissionKey, &actions.DataPermission{DataScope: actions.DataScopeSelf, UserId: 7})
actions.IndexAction(&probeRow{}, &probeIndexReq{}, func() interface{} { return &[]probeRow{} })(c)
var body pageEnvelope
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decoding response body %q: %v", w.Body.String(), err)
}
if body.Code != http.StatusOK {
t.Fatalf("response code = %d, want %d; body=%s", body.Code, http.StatusOK, w.Body.String())
}
sql := cl.all()
const wantFragment = "action_probe_row.create_by = "
if !strings.Contains(sql, wantFragment) {
t.Fatalf("IndexAction did not apply the data-permission scope to its query; want SQL containing %q, got:\n%s", wantFragment, sql)
}
}
+30 -183
View File
@@ -1,201 +1,48 @@
package actions
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"gorm.io/gorm"
contractactions "github.com/go-admin-team/go-admin-core/v2/sdk/contract/actions"
)
type DataPermission struct {
DataScope string
UserId int
DeptId int
RoleId int
}
// DataPermission is a thin alias of go-admin-core's sdk/contract/actions
// (PRD 006 F3/F5).
type DataPermission = contractactions.DataPermission
func PermissionAction() gin.HandlerFunc {
return func(c *gin.Context) {
// Permission() below returns the query untouched when data permission
// is off, so the lookup that feeds it has nothing to feed. It used to
// run anyway: a sys_user join on every list, detail, update and delete,
// with the result discarded.
if !config.ApplicationConfig.EnableDP {
c.Set(PermissionKey, new(DataPermission))
c.Next()
return
}
userId := user.GetUserIdStr(c)
if userId == "" {
c.Set(PermissionKey, new(DataPermission))
c.Next()
return
}
// The token already carries what the scope is decided by. Reading it
// there costs nothing, and goes no more stale than rolekey does - which
// Casbin has always read from the token.
if p, ok := permissionFromClaims(c); ok {
c.Set(PermissionKey, p)
c.Next()
return
}
db, err := pkg.GetOrm(c)
if err != nil {
log.Error(err)
// Same fix as the newDataPermission branch below: without Abort,
// gin's "return means continue" semantics send the request on to
// the business handler with PermissionKey never set. The caller
// then reads a zero-value DataPermission, which used to fall
// into Permission()'s fail-open default - a database hiccup
// silently turning into "see everything". PRD 006 F14/H1.
response.Error(c, 500, err, "权限范围鉴定错误")
c.Abort()
return
}
msgID := pkg.GenerateMsgIDFromContext(c)
p, err := newDataPermission(db, userId)
if err != nil {
log.Errorf("MsgID[%s] PermissionAction error: %s", msgID, err)
response.Error(c, 500, err, "权限范围鉴定错误")
c.Abort()
return
}
c.Set(PermissionKey, p)
c.Next()
}
}
// permissionFromClaims builds the scope from the token, reporting false when
// the token predates deptid being carried. Such a token still exists until it
// expires, and it has to keep working.
func permissionFromClaims(c *gin.Context) (*DataPermission, bool) {
claims := user.ExtractClaims(c)
if claims["deptid"] == nil || claims["datascope"] == nil {
return nil, false
}
scope, ok := claims["datascope"].(string)
if !ok {
return nil, false
}
return &DataPermission{
DataScope: scope,
UserId: user.GetUserId(c),
DeptId: user.GetDeptId(c),
RoleId: user.GetRoleId(c),
}, true
}
func newDataPermission(tx *gorm.DB, userId interface{}) (*DataPermission, error) {
var err error
p := &DataPermission{}
err = tx.Table("sys_user").
Select("sys_user.user_id", "sys_role.role_id", "sys_user.dept_id", "sys_role.data_scope").
Joins("left join sys_role on sys_role.role_id = sys_user.role_id").
Where("sys_user.user_id = ?", userId).
Scan(p).Error
if err != nil {
err = errors.New("获取用户数据出错 msg:" + err.Error())
return nil, err
}
return p, nil
}
// The five values sys_role.data_scope can hold. Front end's role editor
// calls them by the same names (go-admin-ui's sys-role/index.vue): "1" is
// 全部数据权限, "2" 自定义数据权限, "3" 本部门数据权限, "4" 本部门及以下数据权限,
// "5" 仅本人数据权限.
//
// DataScopeAll has to be a named, explicit case in Permission below rather
// than falling into default: it is a real, intentional configuration, not an
// absence of one, and default's job after PRD 006 F14/H2 is to catch values
// that are neither. Folding the two together is what made an unset or
// corrupted data_scope indistinguishable from "show everything" in the first
// place.
// The five values sys_role.data_scope can hold, referenced directly from
// go-admin-core's sdk/contract/actions rather than restated as literals -
// see that package's DataScope* doc comment and PRD 006's hard constraint 4.
const (
DataScopeAll = "1"
DataScopeCustom = "2"
DataScopeDept = "3"
DataScopeDeptTree = "4"
DataScopeSelf = "5"
DataScopeAll = contractactions.DataScopeAll
DataScopeCustom = contractactions.DataScopeCustom
DataScopeDept = contractactions.DataScopeDept
DataScopeDeptTree = contractactions.DataScopeDeptTree
DataScopeSelf = contractactions.DataScopeSelf
)
// IsValidDataScope reports whether s is one of the five values Permission
// recognizes. Anything else lands in Permission's fail-closed default, so
// code that persists data_scope (sys_role writes) should reject it before it
// reaches the database rather than let a typo or an empty string surface
// there silently.
func IsValidDataScope(s string) bool {
switch s {
case DataScopeAll, DataScopeCustom, DataScopeDept, DataScopeDeptTree, DataScopeSelf:
return true
default:
return false
}
// PermissionAction, Permission, GetPermissionFromContext and
// IsValidDataScope forward to go-admin-core's sdk/contract/actions (PRD 006
// F3/F5). create.go/delete.go/index.go/update.go/view.go in this package
// (the generic CRUD actions, which do not move to core) call Permission and
// GetPermissionFromContext by these same names and are unchanged by the
// move: the names now resolve to forwards instead of local definitions, and
// the behaviour is identical either way.
func PermissionAction() gin.HandlerFunc {
return contractactions.PermissionAction()
}
func Permission(tableName string, p *DataPermission) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if !config.ApplicationConfig.EnableDP {
return db
}
switch p.DataScope {
case DataScopeAll:
return db
case DataScopeCustom:
return db.Where(tableName+".create_by in (select sys_user.user_id from sys_role_dept left join sys_user on sys_user.dept_id=sys_role_dept.dept_id where sys_role_dept.role_id = ?)", p.RoleId)
case DataScopeDept:
if p.DeptId <= 0 {
// A department id of 0 identifies no real department (see
// sys_dept.go: dept_path always starts with "/0/", the
// reserved root). Matching it literally would mean "every
// user whose dept_id happens to be unset", not "no one" -
// fail closed instead. PRD 006 F14/H3.
return db.Where("1 = 0")
}
return db.Where(tableName+".create_by in (SELECT user_id from sys_user where dept_id = ? )", p.DeptId)
case DataScopeDeptTree:
if p.DeptId <= 0 {
// dept_path is built as "/0/" + id + "/..." for every
// department (sys_dept.go), so a DeptId of 0 turns the LIKE
// pattern below into '%/0/%', which matches every row in
// sys_dept - full visibility instead of none. PRD 006
// F14/H3.
return db.Where("1 = 0")
}
return db.Where(tableName+".create_by in (SELECT user_id from sys_user where sys_user.dept_id in(select dept_id from sys_dept where dept_path like ? ))", "%/"+pkg.IntToString(p.DeptId)+"/%")
case DataScopeSelf:
return db.Where(tableName+".create_by = ?", p.UserId)
default:
// Unrecognized scope: never configured, corrupted data, or a
// value a future version adds and this one does not know yet.
// Fail closed - match nothing - instead of silently falling
// back to "see everything". PRD 006 F14/H2.
return db.Where("1 = 0")
}
}
return contractactions.Permission(tableName, p)
}
func getPermissionFromContext(c *gin.Context) *DataPermission {
p := new(DataPermission)
if pm, ok := c.Get(PermissionKey); ok {
switch pm.(type) {
case *DataPermission:
p = pm.(*DataPermission)
}
}
return p
}
// GetPermissionFromContext 提供非action写法数据范围约束
func GetPermissionFromContext(c *gin.Context) *DataPermission {
return getPermissionFromContext(c)
return contractactions.GetPermissionFromContext(c)
}
// IsValidDataScope reports whether s is one of the five values Permission
// recognizes. See go-admin-core's sdk/contract/actions.IsValidDataScope.
func IsValidDataScope(s string) bool {
return contractactions.IsValidDataScope(s)
}
+67 -167
View File
@@ -1,4 +1,4 @@
package actions
package actions_test
import (
"net/http"
@@ -6,201 +6,101 @@ import (
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"gorm.io/gorm"
"go-admin/common/actions"
)
// No database is placed in the context on purpose. The middleware needs one
// only to run the sys_user join, so reaching the handler proves it did not.
func runPermission(t *testing.T, claims jwt.MapClaims) (*DataPermission, bool) {
t.Helper()
gin.SetMode(gin.TestMode)
// The detailed data-permission regression suite (claims parsing, the
// GetOrm-unavailable abort, the SQL each data scope produces) now lives in
// go-admin-core's sdk/contract/actions, alongside the logic itself (PRD 006
// F3). What is left to test here is the shim's own wiring: that this
// package's exported names still round-trip through the same *gin.Context
// key core's PermissionAction and GetPermissionFromContext use.
//
// This file lives in package actions_test, an external test, deliberately:
// it exercises PermissionAction and GetPermissionFromContext exactly as an
// app/admin Service does, through this package's public API only, not
// through anything internal a forward could paper over.
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
if claims != nil {
c.Set(jwt.JwtPayloadKey, claims)
}
PermissionAction()(c)
value, exists := c.Get(PermissionKey)
if !exists {
return nil, false
}
p, _ := value.(*DataPermission)
return p, true
}
// Permission() returns the query untouched when data permission is off, so the
// lookup feeding it has nothing to feed. It used to run regardless: a sys_user
// join on every list, detail, update and delete, discarded immediately.
func TestNoLookupWhenDataPermissionIsOff(t *testing.T) {
previous := config.ApplicationConfig.EnableDP
config.ApplicationConfig.EnableDP = false
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
if _, ok := runPermission(t, jwt.MapClaims{"identity": float64(7)}); !ok {
t.Fatal("the request needed a database even though data permission is off")
}
}
func TestScopeComesFromTheTokenWhenItCarriesOne(t *testing.T) {
// TestPermissionKeyMatchesWhatPermissionActionSets guards PRD 006's hard
// constraint 4: PermissionKey must be declared as
// `const PermissionKey = contractactions.PermissionKey`, a direct
// reference, never a restated literal (see type.go). PermissionAction is
// core's middleware and always writes under core's own key. This test reads
// the value back with actions.PermissionKey exactly as code outside
// GetPermissionFromContext would - c.Get(actions.PermissionKey) is a real,
// if uncommon, way to read the value go-admin has always allowed, and it is
// the one call site where an independently declared PermissionKey would
// stop working without GetPermissionFromContext's own forward hiding it.
//
// If PermissionKey were ever re-declared as an independent literal in this
// package, a later edit to core's copy would make this test fail without a
// single byte of this package having changed - which is the silent-failure
// mode hard constraint 4 exists to rule out (evaluation S2).
func TestPermissionKeyMatchesWhatPermissionActionSets(t *testing.T) {
previous := config.ApplicationConfig.EnableDP
config.ApplicationConfig.EnableDP = true
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
p, ok := runPermission(t, jwt.MapClaims{
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{
"identity": float64(7),
"roleid": float64(3),
"deptid": float64(5),
"datascope": "4",
"datascope": actions.DataScopeDeptTree,
})
actions.PermissionAction()(c)
value, ok := c.Get(actions.PermissionKey)
if !ok {
t.Fatal("the token carried the scope and a database was still needed")
t.Fatal("PermissionAction did not set the key actions.PermissionKey names; the two have diverged")
}
if p.DataScope != "4" || p.UserId != 7 || p.DeptId != 5 || p.RoleId != 3 {
t.Fatalf("scope read as %+v", p)
p, ok := value.(*actions.DataPermission)
if !ok || p.DataScope != actions.DataScopeDeptTree || p.DeptId != 5 {
t.Fatalf("value under actions.PermissionKey = %#v, want a DataPermission carrying the token's scope", value)
}
}
// A token minted before deptid was carried is still valid until it expires, and
// has to keep working - by falling back to the query, which needs a database.
func TestATokenWithoutDeptIdFallsBackToTheQuery(t *testing.T) {
previous := config.ApplicationConfig.EnableDP
config.ApplicationConfig.EnableDP = true
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
if _, ok := runPermission(t, jwt.MapClaims{
"identity": float64(7),
"roleid": float64(3),
"datascope": "4",
}); ok {
t.Fatal("an old token was served from claims it does not have")
}
}
// PRD 006 F14/H1. A token without deptid/datascope forces the fallback
// query, which needs pkg.GetOrm(c) - and no "db" key is set in this
// context, so GetOrm fails exactly as it would if a tenant's database were
// unreachable. Before the fix, that error was logged and the handler ran
// anyway with no data permission filter at all.
func TestPermissionActionAbortsWhenDBIsUnavailable(t *testing.T) {
// TestGetPermissionFromContextRoundTrips is the same guard from the other
// exported entry point: GetPermissionFromContext must read back exactly
// what PermissionAction wrote, both reached through this package's own
// forwards rather than core's directly.
func TestGetPermissionFromContextRoundTrips(t *testing.T) {
previous := config.ApplicationConfig.EnableDP
config.ApplicationConfig.EnableDP = true
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
gin.SetMode(gin.TestMode)
r := gin.New()
handlerReached := false
r.Use(func(c *gin.Context) {
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"identity": float64(7)})
})
r.Use(PermissionAction())
r.GET("/", func(c *gin.Context) {
handlerReached = true
c.Status(http.StatusOK)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{
"identity": float64(7),
"roleid": float64(3),
"deptid": float64(5),
"datascope": actions.DataScopeSelf,
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
actions.PermissionAction()(c)
if handlerReached {
t.Fatal("the business handler ran with no database and no data permission filter set")
p := actions.GetPermissionFromContext(c)
if p.DataScope != actions.DataScopeSelf || p.UserId != 7 {
t.Fatalf("GetPermissionFromContext() = %+v, want DataScope=%q UserId=7", p, actions.DataScopeSelf)
}
}
// PRD 006 F14/H2 and H3. Table-driven over gorm DryRun so the exact SQL
// Permission produces for each scope is pinned down, not just "some WHERE
// clause got added".
func TestPermissionScopes(t *testing.T) {
previous := config.ApplicationConfig.EnableDP
config.ApplicationConfig.EnableDP = true
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DryRun: true})
if err != nil {
t.Fatalf("open: %v", err)
func TestIsValidDataScope(t *testing.T) {
for _, s := range []string{actions.DataScopeAll, actions.DataScopeCustom, actions.DataScopeDept, actions.DataScopeDeptTree, actions.DataScopeSelf} {
if !actions.IsValidDataScope(s) {
t.Errorf("IsValidDataScope(%q) = false, want true", s)
}
}
const noRows = "SELECT * FROM `t` WHERE 1 = 0"
cases := []struct {
name string
p *DataPermission
want string
vars []interface{}
}{
{
name: "all",
p: &DataPermission{DataScope: DataScopeAll},
want: "SELECT * FROM `t`",
},
{
name: "custom",
p: &DataPermission{DataScope: DataScopeCustom, RoleId: 3},
want: "SELECT * FROM `t` WHERE t.create_by in (select sys_user.user_id from sys_role_dept left join sys_user on sys_user.dept_id=sys_role_dept.dept_id where sys_role_dept.role_id = ?)",
vars: []interface{}{3},
},
{
name: "dept",
p: &DataPermission{DataScope: DataScopeDept, DeptId: 5},
want: "SELECT * FROM `t` WHERE t.create_by in (SELECT user_id from sys_user where dept_id = ? )",
vars: []interface{}{5},
},
{
name: "dept-tree",
p: &DataPermission{DataScope: DataScopeDeptTree, DeptId: 5},
want: "SELECT * FROM `t` WHERE t.create_by in (SELECT user_id from sys_user where sys_user.dept_id in(select dept_id from sys_dept where dept_path like ? ))",
vars: []interface{}{"%/5/%"},
},
{
name: "self",
p: &DataPermission{DataScope: DataScopeSelf, UserId: 7},
want: "SELECT * FROM `t` WHERE t.create_by = ?",
vars: []interface{}{7},
},
// H2: an unrecognized scope must not read like "all data" any more.
{name: "unrecognized value", p: &DataPermission{DataScope: "6"}, want: noRows},
// H2/H1: the zero-value DataPermission is what getPermissionFromContext
// and the two "give up and continue" branches in PermissionAction hand
// out when nothing else is available.
{name: "zero value (no scope at all)", p: &DataPermission{}, want: noRows},
// H3: dept_path always starts with "/0/" (sys_dept.go), so DeptId 0
// must not be allowed to build a pattern that matches every row.
{name: "dept with DeptId 0", p: &DataPermission{DataScope: DataScopeDept, DeptId: 0}, want: noRows},
{name: "dept-tree with DeptId 0", p: &DataPermission{DataScope: DataScopeDeptTree, DeptId: 0}, want: noRows},
{name: "dept-tree with negative DeptId", p: &DataPermission{DataScope: DataScopeDeptTree, DeptId: -1}, want: noRows},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
stmt := db.Session(&gorm.Session{DryRun: true}).
Table("t").
Scopes(Permission("t", tc.p)).
Find(&[]map[string]interface{}{}).
Statement
if stmt.SQL.String() != tc.want {
t.Errorf("SQL = %q, want %q", stmt.SQL.String(), tc.want)
}
if tc.vars == nil {
if len(stmt.Vars) != 0 {
t.Errorf("vars = %v, want none", stmt.Vars)
}
return
}
if len(stmt.Vars) != len(tc.vars) {
t.Fatalf("vars = %v, want %v", stmt.Vars, tc.vars)
}
for i := range tc.vars {
if stmt.Vars[i] != tc.vars[i] {
t.Errorf("vars[%d] = %v, want %v", i, stmt.Vars[i], tc.vars[i])
}
}
})
if actions.IsValidDataScope("6") {
t.Error(`IsValidDataScope("6") = true, want false`)
}
}
+11 -3
View File
@@ -1,5 +1,13 @@
package actions
const (
PermissionKey = "dataPermission"
)
import contractactions "github.com/go-admin-team/go-admin-core/v2/sdk/contract/actions"
// PermissionKey is a direct reference to go-admin-core's sdk/contract/actions
// constant, not a restated literal - see that package's PermissionKey doc
// comment. PRD 006's hard constraint 4 requires this form for exactly this
// symbol: PermissionAction (below) sets the gin context key it owns, and
// GetPermissionFromContext reads it back; an independently declared literal
// here would let the two silently drift apart if core's copy ever changed
// without this one following. common/actions/shim_test.go carries the
// regression test for that failure mode.
const PermissionKey = contractactions.PermissionKey
+12 -71
View File
@@ -1,74 +1,15 @@
package dto
type AutoForm struct {
Fields []Field `json:"fields"`
FormRef string `json:"formRef"`
FormModel string `json:"formModel"`
Size string `json:"size"`
LabelPosition string `json:"labelPosition"`
LabelWidth int `json:"labelWidth"`
FormRules string `json:"formRules"`
Gutter int `json:"gutter"`
Disabled bool `json:"disabled"`
Span int `json:"span"`
FormBtns bool `json:"formBtns"`
}
import contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
type Config struct {
Label string `json:"label"`
LabelWidth interface{} `json:"labelWidth"`
ShowLabel bool `json:"showLabel"`
ChangeTag bool `json:"changeTag"`
Tag string `json:"tag"`
TagIcon string `json:"tagIcon"`
Required bool `json:"required"`
Layout string `json:"layout"`
Span int `json:"span"`
Document string `json:"document"`
RegList []interface{} `json:"regList"`
FormId int `json:"formId"`
RenderKey int64 `json:"renderKey"`
DefaultValue interface{} `json:"defaultValue"`
ShowTip bool `json:"showTip,omitempty"`
ButtonText string `json:"buttonText,omitempty"`
FileSize int `json:"fileSize,omitempty"`
SizeUnit string `json:"sizeUnit,omitempty"`
}
type Option struct {
Label string `json:"label"`
Value string `json:"value"`
}
type Slot struct {
Prepend string `json:"prepend,omitempty"`
Append string `json:"append,omitempty"`
ListType bool `json:"list-type,omitempty"`
Options []Option `json:"options,omitempty"`
}
type Field struct {
Config Config `json:"__config__"`
Slot Slot `json:"__slot__"`
Placeholder string `json:"placeholder,omitempty"`
Style Style `json:"style,omitempty"`
Clearable bool `json:"clearable,omitempty"`
PrefixIcon string `json:"prefix-icon,omitempty"`
SuffixIcon string `json:"suffix-icon,omitempty"`
Maxlength interface{} `json:"maxlength"`
ShowWordLimit bool `json:"show-word-limit,omitempty"`
Readonly bool `json:"readonly,omitempty"`
Disabled bool `json:"disabled"`
VModel string `json:"__vModel__"`
Action string `json:"action,omitempty"`
Accept string `json:"accept,omitempty"`
Name string `json:"name,omitempty"`
AutoUpload bool `json:"auto-upload,omitempty"`
ListType string `json:"list-type,omitempty"`
Multiple bool `json:"multiple,omitempty"`
Filterable bool `json:"filterable,omitempty"`
}
type Style struct {
Width string `json:"width"`
}
// AutoForm and the types below describe a form built by go-admin-ui's form
// designer. They are thin aliases of go-admin-core's sdk/contract/dto (PRD
// 006 F2/F5).
type (
AutoForm = contractdto.AutoForm
Config = contractdto.Config
Option = contractdto.Option
Slot = contractdto.Slot
Field = contractdto.Field
Style = contractdto.Style
)
+7 -102
View File
@@ -1,106 +1,11 @@
package dto
import (
vd "github.com/bytedance/go-tagexpr/v2/validator"
"net/http"
import contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
// ObjectById, ObjectGetReq and ObjectDeleteReq are thin aliases of
// go-admin-core's sdk/contract/dto (PRD 006 F2/F5).
type (
ObjectById = contractdto.ObjectById
ObjectGetReq = contractdto.ObjectGetReq
ObjectDeleteReq = contractdto.ObjectDeleteReq
)
type ObjectById struct {
Id int `uri:"id"`
Ids []int `json:"ids"`
}
func (s *ObjectById) Bind(ctx *gin.Context) error {
var err error
log := api.GetRequestLogger(ctx)
err = ctx.ShouldBindUri(s)
if err != nil {
log.Warnf("ShouldBindUri error: %s", err.Error())
return err
}
if ctx.Request.Method == http.MethodDelete {
err = ctx.ShouldBind(&s)
if err != nil {
log.Warnf("ShouldBind error: %s", err.Error())
return err
}
if len(s.Ids) > 0 {
return nil
}
if s.Ids == nil {
s.Ids = make([]int, 0)
}
if s.Id != 0 {
s.Ids = append(s.Ids, s.Id)
}
}
if err = vd.Validate(s); err != nil {
log.Errorf("Validate error: %s", err.Error())
return err
}
return err
}
func (s *ObjectById) GetId() interface{} {
if len(s.Ids) > 0 {
s.Ids = append(s.Ids, s.Id)
return s.Ids
}
return s.Id
}
type ObjectGetReq struct {
Id int `uri:"id"`
}
func (s *ObjectGetReq) Bind(ctx *gin.Context) error {
var err error
log := api.GetRequestLogger(ctx)
err = ctx.ShouldBindUri(s)
if err != nil {
log.Warnf("ShouldBindUri error: %s", err.Error())
return err
}
if err = vd.Validate(s); err != nil {
log.Errorf("Validate error: %s", err.Error())
return err
}
return err
}
func (s *ObjectGetReq) GetId() interface{} {
return s.Id
}
type ObjectDeleteReq struct {
Ids []int `json:"ids"`
}
func (s *ObjectDeleteReq) Bind(ctx *gin.Context) error {
var err error
log := api.GetRequestLogger(ctx)
err = ctx.ShouldBind(&s)
if err != nil {
log.Warnf("ShouldBind error: %s", err.Error())
return err
}
if len(s.Ids) > 0 {
return nil
}
if s.Ids == nil {
s.Ids = make([]int, 0)
}
if err = vd.Validate(s); err != nil {
log.Errorf("Validate error: %s", err.Error())
return err
}
return err
}
func (s *ObjectDeleteReq) GetId() interface{} {
return s.Ids
}
+6 -4
View File
@@ -2,11 +2,13 @@ package dto
import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
)
// OrderDest forwards to go-admin-core's sdk/contract/dto (PRD 006 F2/F5). A
// function cannot be aliased the way a type can, so this is a pure
// pass-through rather than a `func X = pkg.X` form Go does not have.
func OrderDest(sort string, bl bool) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Order(clause.OrderByColumn{Column: clause.Column{Name: sort}, Desc: bl})
}
return contractdto.OrderDest(sort, bl)
}
+4 -17
View File
@@ -1,20 +1,7 @@
package dto
type Pagination struct {
PageIndex int `form:"pageIndex"`
PageSize int `form:"pageSize"`
}
import contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
func (m *Pagination) GetPageIndex() int {
if m.PageIndex <= 0 {
m.PageIndex = 1
}
return m.PageIndex
}
func (m *Pagination) GetPageSize() int {
if m.PageSize <= 0 {
m.PageSize = 10
}
return m.PageSize
}
// Pagination is a thin alias of go-admin-core's sdk/contract/dto (PRD 006
// F2/F5).
type Pagination = contractdto.Pagination
+19 -68
View File
@@ -1,80 +1,31 @@
package dto
import (
"github.com/go-admin-team/go-admin-core/v2/tools/search"
"go-admin/common/global"
"gorm.io/gorm"
contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
)
type GeneralDelDto struct {
Id int `uri:"id" json:"id" validate:"required"`
Ids []int `json:"ids"`
}
func (g GeneralDelDto) GetIds() []int {
ids := make([]int, 0)
// Id 此前在 else 分支里被重复追加:仅传 Id 时会得到 [5 5],
// 同一条记录被执行两次删除
if g.Id > 0 {
ids = append(ids, g.Id)
}
for _, id := range g.Ids {
if id > 0 {
ids = append(ids, id)
}
}
if len(ids) == 0 {
//方式全部删除
ids = append(ids, 0)
}
return ids
}
type GeneralGetDto struct {
Id int `uri:"id" json:"id" validate:"required"`
}
// GeneralDelDto and GeneralGetDto are thin aliases of go-admin-core's
// sdk/contract/dto (PRD 006 F2/F5).
type (
GeneralDelDto = contractdto.GeneralDelDto
GeneralGetDto = contractdto.GeneralGetDto
)
// MakeCondition and Paginate forward to go-admin-core's sdk/contract/dto
// (PRD 006 F2/F5). This file used to read go-admin/common/global.Driver to
// pick the SQL dialect MakeCondition resolves search tags against; the
// lowered version instead reads db.Dialector.Name() from inside the closure
// it returns, which is always the driver the caller's own *gorm.DB is bound
// to - correct even when a multi-tenant host has more than one database
// open with different drivers, which a single package-level variable could
// never be. global.Driver itself is untouched and still readable, but
// nothing in this package reads it anymore.
func MakeCondition(q interface{}) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
condition := &search.GormCondition{
GormPublic: search.GormPublic{},
Join: make([]*search.GormJoin, 0),
}
search.ResolveSearchQuery(global.Driver, q, condition)
for _, join := range condition.Join {
if join == nil {
continue
}
db = db.Joins(join.JoinOn)
for k, v := range join.Where {
db = db.Where(k, v...)
}
for k, v := range join.Or {
db = db.Or(k, v...)
}
for _, o := range join.Order {
db = db.Order(o)
}
}
for k, v := range condition.Where {
db = db.Where(k, v...)
}
for k, v := range condition.Or {
db = db.Or(k, v...)
}
for _, o := range condition.Order {
db = db.Order(o)
}
return db
}
return contractdto.MakeCondition(q)
}
func Paginate(pageSize, pageIndex int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
offset := (pageIndex - 1) * pageSize
if offset < 0 {
offset = 0
}
return db.Offset(offset).Limit(pageSize)
}
return contractdto.Paginate(pageSize, pageIndex)
}
+7 -18
View File
@@ -1,21 +1,10 @@
package dto
import (
"github.com/gin-gonic/gin"
"go-admin/common/models"
import contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
// Index and Control are thin aliases of go-admin-core's sdk/contract/dto
// (PRD 006 F2/F5).
type (
Index = contractdto.Index
Control = contractdto.Control
)
type Index interface {
Generate() Index
Bind(ctx *gin.Context) error
GetPageIndex() int
GetPageSize() int
GetNeedSearch() interface{}
}
type Control interface {
Generate() Control
Bind(ctx *gin.Context) error
GenerateM() (models.ActiveRecord, error)
GetId() interface{}
}
+7
View File
@@ -7,5 +7,12 @@ const (
var (
// Driver 数据库驱动
//
// Deprecated: common/dto.MakeCondition stopped reading this after PRD
// 006 F2/F5 - it now takes the dialect from the *gorm.DB passed to the
// scope it returns instead of this process-wide variable. Driver is
// still set (common/database/initialize.go) and still readable for fork
// code that reads it directly, but it is no longer this framework's own
// path to the current SQL dialect.
Driver string
)
+27
View File
@@ -59,6 +59,33 @@ func AuthCheckRole() gin.HandlerFunc {
}
}
// EnforceRoleFor reports whether the caller's role has explicit Casbin
// permission to act on path with method.
//
// AuthCheckRole never calls Enforce for a route CasbinExclude lists - that
// is the whole point of the list. A handler on such a route can still need
// the real answer for part of what it does: sys_user.go's Update shares its
// excluded route between the personal-center screen editing the caller's own
// record (which is why the route is excluded at all) and an admin editing
// someone else's, and only the second case is meant to require a policy
// grant. That handler asks here instead of assuming the middleware already
// checked.
func EnforceRoleFor(c *gin.Context, path, method string) (bool, error) {
data, ok := c.Get(jwtauth.JwtPayloadKey)
if !ok {
return false, nil
}
v, ok := data.(jwtauth.MapClaims)
if !ok {
return false, nil
}
if v["rolekey"] == "admin" {
return true, nil
}
e := sdk.Runtime.GetCasbinByTenant(c.Request.Host)
return e.Enforce(v["rolekey"], path, method)
}
// excludedFromCasbin reports whether the route skips the permission check.
//
// It runs for every non-admin request, so the order matters: the method rules
+9 -37
View File
@@ -1,41 +1,13 @@
package models
import (
"time"
import contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
"gorm.io/plugin/soft_delete"
// ControlBy, Model and ModelTime are thin aliases of go-admin-core's
// sdk/contract/models (PRD 006 F1/F5). A type alias is the same type, not a
// new one, so every model that embeds these keeps its GORM tags, JSON tags
// and method set untouched.
type (
ControlBy = contractmodels.ControlBy
Model = contractmodels.Model
ModelTime = contractmodels.ModelTime
)
type ControlBy struct {
CreateBy int `json:"createBy" gorm:"index;comment:创建者"`
UpdateBy int `json:"updateBy" gorm:"index;comment:更新者"`
}
// SetCreateBy 设置创建人id
func (e *ControlBy) SetCreateBy(createBy int) {
e.CreateBy = createBy
}
// SetUpdateBy 设置修改人id
func (e *ControlBy) SetUpdateBy(updateBy int) {
e.UpdateBy = updateBy
}
type Model struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement;comment:主键编码"`
}
type ModelTime struct {
CreatedAt time.Time `json:"createdAt" gorm:"comment:创建时间"`
UpdatedAt time.Time `json:"updatedAt" gorm:"comment:最后更新时间"`
// DeletedAt is milliseconds since the epoch, zero while the row is live,
// and never null.
//
// A nullable marker cannot take part in a unique index. Two live rows are
// (name, NULL) and (name, NULL), and NULL is not equal to NULL, so the
// index permits both — it looks like a constraint and enforces nothing.
// With zero for live rows the pair collides, while two deletions of the
// same name differ by their timestamps and both remain.
DeletedAt soft_delete.DeletedAt `json:"-" gorm:"softDelete:milli;index;comment:删除时间"`
}
+11 -7
View File
@@ -1,11 +1,15 @@
package models
// Menu 菜单中的类型枚举值
import contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
// Directory, Menu and Button are the menu type enum values used by
// sys_menu.menu_type, referenced directly from go-admin-core's
// sdk/contract/models rather than restated as literals: PRD 006's hard
// constraint 4 requires `const X = pkg.X` for exactly this reason - two
// independently written copies of the same value can be edited out of step,
// where a direct reference cannot.
const (
// Directory 目录
Directory string = "M"
// Menu 菜单
Menu string = "C"
// Button 按钮
Button string = "F"
Directory = contractmodels.Directory
Menu = contractmodels.Menu
Button = contractmodels.Button
)
+7 -20
View File
@@ -1,23 +1,10 @@
package models
import "time"
import contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
type Migration struct {
Version string `gorm:"primaryKey"`
ApplyTime time.Time `gorm:"autoCreateTime"`
// AppCode identifies which app registered this migration. The empty string
// means the framework itself.
//
// NOT NULL DEFAULT '' rather than a nullable column, and the difference is
// not cosmetic: on a nullable column the rows that already exist when
// AutoMigrate adds it hold NULL, and the first SELECT scanning one into
// this string field fails with "converting NULL to string is unsupported".
// The default is what makes "existing history belongs to the framework"
// true without a backfill script anyone could forget to run.
AppCode string `gorm:"type:varchar(64);not null;default:'';index:idx_sys_migration_app_code;comment:AppCode"`
}
func (Migration) TableName() string {
return "sys_migration"
}
// Migration is the sys_migration row model (data). It is unrelated to
// cmd/migrate/migration.Migration, the in-process registration table this
// package's TableName has nothing to do with - see
// go-admin-core's sdk/contract/models.Migration doc comment for why the two
// share a name.
type Migration = contractmodels.Migration
+7 -27
View File
@@ -1,30 +1,10 @@
package models
type Response struct {
// 代码
Code int `json:"code" example:"200"`
// 数据集
Data interface{} `json:"data"`
// 消息
Msg string `json:"msg"`
RequestId string `json:"requestId"`
}
import contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
type Page struct {
List interface{} `json:"list"`
Count int `json:"count"`
PageIndex int `json:"pageIndex"`
PageSize int `json:"pageSize"`
}
// ReturnOK 正常返回
func (res *Response) ReturnOK() *Response {
res.Code = 200
return res
}
// ReturnError 错误返回
func (res *Response) ReturnError(code int) *Response {
res.Code = code
return res
}
// Response and Page are thin aliases of go-admin-core's sdk/contract/models
// (PRD 006 F1/F5).
type (
Response = contractmodels.Response
Page = contractmodels.Page
)
+9 -8
View File
@@ -1,11 +1,12 @@
package models
import "gorm.io/gorm/schema"
import contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
type ActiveRecord interface {
schema.Tabler
SetCreateBy(createBy int)
SetUpdateBy(updateBy int)
Generate() ActiveRecord
GetId() interface{}
}
// ActiveRecord is self-referencing (Generate() ActiveRecord), which is why
// it must stay a type alias rather than a defined type: aliasing preserves
// identity with go-admin-core's sdk/contract/models.ActiveRecord, so a
// model whose Generate() returns that interface still satisfies this one. A
// defined type here would break every implementer's method set - see
// go-admin-core's sdk/contract/models package tests for the counterproof
// (PRD 006 counterproof A).
type ActiveRecord = contractmodels.ActiveRecord
+4 -39
View File
@@ -1,42 +1,7 @@
package models
import (
"gorm.io/gorm"
import contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
)
// BaseUser 密码登录基础用户
type BaseUser struct {
Username string `json:"username" gorm:"type:varchar(100);comment:用户名"`
Salt string `json:"-" gorm:"type:varchar(255);comment:加盐;<-"`
PasswordHash string `json:"-" gorm:"type:varchar(128);comment:密码hash;<-"`
Password string `json:"password" gorm:"-"`
}
// SetPassword 设置密码
func (u *BaseUser) SetPassword(value string) {
u.Password = value
u.generateSalt()
u.PasswordHash = u.GetPasswordHash()
}
// GetPasswordHash 获取密码hash
func (u *BaseUser) GetPasswordHash() string {
passwordHash, err := pkg.SetPassword(u.Password, u.Salt)
if err != nil {
return ""
}
return passwordHash
}
// generateSalt 生成加盐值
func (u *BaseUser) generateSalt() {
u.Salt = pkg.GenerateRandomKey16()
}
// Verify 验证密码
func (u *BaseUser) Verify(db *gorm.DB, tableName string) bool {
db.Table(tableName).Where("username = ?", u.Username).First(u)
return u.GetPasswordHash() == u.PasswordHash
}
// BaseUser is a thin alias of go-admin-core's sdk/contract/models (PRD 006
// F1/F5).
type BaseUser = contractmodels.BaseUser
+167
View File
@@ -0,0 +1,167 @@
// Package apis is app-order's HTTP layer: four hand-written gin handlers,
// none of them a wrapper around core's generic CRUD Actions. See
// service/order.go's package doc for why.
package apis
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/actions"
// models.Response in the @Success annotations below resolves to
// go-admin-core's sdk/contract/models.Response, not to this package -
// swaggo finds it through --parseDependency. It is the envelope with a
// data field; core's response.Response, which the framework's own
// handlers name, has no data field and would document these endpoints
// as returning none.
"github.com/go-admin-team/example-app-order/models"
"github.com/go-admin-team/example-app-order/service"
orderdto "github.com/go-admin-team/example-app-order/service/dto"
)
// Order embeds api.Api the same way every hand-written go-admin handler
// does (see app/admin/apis/sys_post.go): MakeContext/MakeOrm/Bind/
// MakeService/OK/Error/PageOK are all core, imported with no dependency on
// go-admin itself.
type Order struct {
api.Api
}
// GetPage
// @Summary List orders visible to the caller's data scope
// @Tags order
// @Param status query string false "status"
// @Param orderNo query string false "orderNo"
// @Param pageIndex query int false "pageIndex"
// @Param pageSize query int false "pageSize"
// @Success 200 {object} models.Response
// @Router /api/v1/order [get]
// @Security Bearer
func (e Order) GetPage(c *gin.Context) {
s := service.Order{}
req := orderdto.OrderSearchReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, binding.Form).
MakeService(&s.Service).
Errors
if err != nil {
e.Error(http.StatusUnprocessableEntity, err, err.Error())
return
}
p := actions.GetPermissionFromContext(c)
list := make([]models.Order, 0)
count, err := s.GetPage(&req, p, &list)
if err != nil {
e.Logger.Error(err)
e.Error(http.StatusInternalServerError, err, "failed to list orders")
return
}
e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "ok")
}
// Get
// @Summary Get one order and its items
// @Tags order
// @Param id path int true "order id"
// @Success 200 {object} models.Response
// @Router /api/v1/order/{id} [get]
// @Security Bearer
func (e Order) Get(c *gin.Context) {
s := service.Order{}
req := orderdto.OrderIdReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, nil).
MakeService(&s.Service).
Errors
if err != nil {
e.Error(http.StatusUnprocessableEntity, err, err.Error())
return
}
p := actions.GetPermissionFromContext(c)
var order models.Order
if err = s.Get(req.Id, p, &order); err != nil {
e.Error(http.StatusNotFound, err, "order not found")
return
}
e.OK(order, "ok")
}
// Create
// @Summary Place a new order
// @Tags order
// @Accept application/json
// @Param data body orderdto.OrderCreateReq true "data"
// @Success 200 {object} models.Response
// @Router /api/v1/order [post]
// @Security Bearer
func (e Order) Create(c *gin.Context) {
s := service.Order{}
req := orderdto.OrderCreateReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, binding.JSON).
MakeService(&s.Service).
Errors
if err != nil {
e.Error(http.StatusUnprocessableEntity, err, err.Error())
return
}
order, err := s.Create(&req, user.GetUserId(c))
if err != nil {
if errors.Is(err, service.ErrOrderEmpty) {
e.Error(http.StatusBadRequest, err, err.Error())
return
}
e.Logger.Error(err)
e.Error(http.StatusInternalServerError, err, "failed to create order")
return
}
e.OK(order, "created")
}
// Pay
// @Summary Mark a pending order as paid
// @Tags order
// @Param id path int true "order id"
// @Success 200 {object} models.Response
// @Router /api/v1/order/{id}/pay [put]
// @Security Bearer
func (e Order) Pay(c *gin.Context) {
s := service.Order{}
req := orderdto.OrderIdReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, nil).
MakeService(&s.Service).
Errors
if err != nil {
e.Error(http.StatusUnprocessableEntity, err, err.Error())
return
}
p := actions.GetPermissionFromContext(c)
if err = s.Pay(req.Id, p); err != nil {
if errors.Is(err, service.ErrOrderNotPending) {
// Deliberately the same response whether the order does not
// exist, is already paid, or is outside p's data scope - see
// service.Order.Pay's doc comment.
e.Error(http.StatusConflict, err, err.Error())
return
}
e.Logger.Error(err)
e.Error(http.StatusInternalServerError, err, "payment failed")
return
}
e.OK(nil, "paid")
}
+81
View File
@@ -0,0 +1,81 @@
module github.com/go-admin-team/example-app-order
go 1.25.13
require (
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/go-admin-team/go-admin-core/v2 v2.5.0
gorm.io/gorm v1.31.2
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/andeya/ameda v1.5.3 // indirect
github.com/andeya/goutil v1.0.1 // indirect
github.com/bitly/go-simplejson v0.5.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/bytedance/go-tagexpr/v2 v2.9.11 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/casbin/casbin/v3 v3.8.1 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99 // indirect
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nyaruka/phonenumbers v1.2.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/redis/go-redis/v9 v9.22.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.1 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.39.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gorm.io/plugin/soft_delete v1.2.1 // indirect
modernc.org/libc v1.67.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.42.2 // indirect
)
+252
View File
@@ -0,0 +1,252 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/andeya/ameda v1.5.3 h1:SvqnhQPZwwabS8HQTRGfJwWPl2w9ZIPInHAw9aE1Wlk=
github.com/andeya/ameda v1.5.3/go.mod h1:FQDHRe1I995v6GG+8aJ7UIUToEmbdTJn/U26NCPIgXQ=
github.com/andeya/goutil v1.0.1 h1:eiYwVyAnnK0dXU5FJsNjExkJW4exUGn/xefPt3k4eXg=
github.com/andeya/goutil v1.0.1/go.mod h1:jEG5/QnnhG7yGxwFUX6Q+JGMif7sjdHmmNVjn7nhJDo=
github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/go-tagexpr/v2 v2.9.11 h1:jJgmoDKPKacGl0llPYbYL/+/2N+Ng0vV0ipbnVssXHY=
github.com/bytedance/go-tagexpr/v2 v2.9.11/go.mod h1:UAyKh4ZRLBPGsyTRFZoPqTni1TlojMdOJXQnEIPCX84=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/casbin/casbin/v3 v3.8.1 h1:D4dEY4knePPR4YgNP5WZtWNaOxD0UK0LpPy9+zxtBwo=
github.com/casbin/casbin/v3 v3.8.1/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99 h1:K62Lb6bsgLOB++z/VAvRvtiEBdNCuMfmQGTGGWMdPpM=
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99/go.mod h1:9+sJ9zvvkXC5sPjPEZM3Jpb9n2Q2VtcrGZly0UHYF5I=
github.com/chanxuehong/util v0.0.0-20200304121633-ca8141845b13/go.mod h1:XEYt99iTxMqkv+gW85JX/DdUINHUe43Sbe5AtqSaDAQ=
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd h1:v3JNsFZmplLO/Cmiyr/rGvR7lW1ld9lB+d5h4yR0MTI=
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd/go.mod h1:mysjrtCs9MmN8hqDf4/mc4eQ26Rt9s1p5oO+fhJlLB4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-admin-team/go-admin-core/v2 v2.5.0 h1:aD1SALklBxizGB9u8cOgm4OT8z656FM83F4fD6dMz9g=
github.com/go-admin-team/go-admin-core/v2 v2.5.0/go.mod h1:LG/XvEfOplbuadKrPTPm0Nu5pN06aQUNZZC3ao4B4gs=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nyaruka/phonenumbers v1.0.55/go.mod h1:sDaTZ/KPX5f8qyV9qN+hIm+4ZBARJrupC6LuhshJq1U=
github.com/nyaruka/phonenumbers v1.2.2 h1:OwVjf7Y4uHoK9VJUrA8ebR0ha2yc6sEYbfrwkq0asCY=
github.com/nyaruka/phonenumbers v1.2.2/go.mod h1:wzk2qq7qwsaBKrfbkWKdgHYOOH+QFTesSpIq53ELw8M=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.23.0/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/plugin/soft_delete v1.2.1 h1:qx9D/c4Xu6w5KT8LviX8DgLcB9hkKl6JC9f44Tj7cGU=
gorm.io/plugin/soft_delete v1.2.1/go.mod h1:Zv7vQctOJTGOsJ/bWgrN1n3od0GBAZgnLjEx+cApLGk=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+89
View File
@@ -0,0 +1,89 @@
// Package migration registers app-order's one migration: create its two
// tables and seed the menu/API entries the admin UI needs to expose them.
//
// It registers through contract/migration.ForApp - the package-level
// facade, not a private NewRegistry() - because that is the only registry a
// third-party app, which cannot reach into the host process, can register
// against and have any hope of the host's own execution engine picking up.
// Whether it actually does, today, is a different question: see this
// package's test file and the gap list in the accompanying report.
package migration
import (
"gorm.io/gorm"
contractmigration "github.com/go-admin-team/go-admin-core/v2/sdk/contract/migration"
contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed"
"github.com/go-admin-team/example-app-order/models"
)
// AppCode is app-order's migration.ForApp / seed.SeedMenus identity.
const AppCode = "order"
// version is this migration's sys_migration key before ForApp namespaces
// it (see contract/migration.ForApp's doc comment: the stored key becomes
// "order-" + version). It follows the framework's own 13-digit millisecond
// timestamp convention purely so a human reading sys_migration.version
// alongside the framework's own rows can still eyeball roughly when it was
// authored; contract/migration.ForApp does not require that shape, just
// uniqueness within this app's own namespace.
const version = "1793800000000"
func init() {
contractmigration.ForApp(AppCode).SetVersion(version, createOrderSchema)
}
// createOrderSchema creates app_order/app_order_item and seeds the menu and
// API entries a host's Seeder turns into sys_menu/sys_api/sys_menu_api_rule
// rows (and, once an administrator grants the menu to a role through the
// ordinary admin UI, casbin_rule). See seed.Seeder's security note: this
// call does not sandbox anything, it only saves app-order from needing to
// know go-admin's own schema.
func createOrderSchema(db *gorm.DB, migrationVersion, appCode string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&models.Order{}, &models.OrderItem{}); err != nil {
return err
}
menus := []seed.MenuSpec{
{
Code: "dir", Kind: contractmodels.Directory,
Title: "Order Example", Path: "/apps/order", Component: "Layout",
Icon: "shopping", Sort: 20,
},
{
Code: "list", Parent: "dir", Kind: contractmodels.Menu,
Title: "Orders", Path: "list",
// Component must start with "apps/<code>/" - see
// seed.MenuSpec.Component's doc comment. This is the one
// concrete rule the report's gap list has nothing bad to
// say about: it is documented exactly where a caller
// building a MenuSpec would look.
Component: "apps/order/order/index",
Sort: 1,
ApiCodes: []string{"list", "get", "create", "pay"},
},
{
Code: "btn-create", Parent: "list", Kind: contractmodels.Button,
Title: "Create", Permission: "order:order:create", Sort: 1,
},
{
Code: "btn-pay", Parent: "list", Kind: contractmodels.Button,
Title: "Pay", Permission: "order:order:pay", Sort: 2,
},
}
apis := []seed.ApiSpec{
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"},
{Code: "get", Title: "Order detail", Path: "/api/v1/order/:id", Method: "GET", Handle: "apis.Order.Get-fm"},
{Code: "create", Title: "Create order", Path: "/api/v1/order", Method: "POST", Handle: "apis.Order.Create-fm"},
{Code: "pay", Title: "Pay order", Path: "/api/v1/order/:id/pay", Method: "PUT", Handle: "apis.Order.Pay-fm"},
}
if err := seed.SeedMenus(tx, appCode, menus, apis); err != nil {
return err
}
return tx.Create(&contractmodels.Migration{Version: migrationVersion, AppCode: appCode}).Error
})
}
@@ -0,0 +1,153 @@
package migration
import (
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
contractmigration "github.com/go-admin-team/go-admin-core/v2/sdk/contract/migration"
contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed"
"github.com/go-admin-team/example-app-order/models"
)
// fakeSeeder stands in for a host's real Seeder (the one wt-shim, as of
// this writing, never registers - see the accompanying report's gap list).
// It records what it received instead of writing to any table, which is
// enough to check app-order's own MenuSpec/ApiSpec assembly without
// depending on go-admin's sys_menu/sys_api schema.
type fakeSeeder struct {
appCode string
menus []seed.MenuSpec
apis []seed.ApiSpec
}
func (f *fakeSeeder) SeedMenus(tx *gorm.DB, appCode string, menus []seed.MenuSpec, apis []seed.ApiSpec) error {
f.appCode = appCode
f.menus = menus
f.apis = apis
return nil
}
// seed.RegisterSeeder panics on a second call in the same process (see its
// doc comment) - by design, there is no public way to unregister one. This
// package's tests share the one registration below rather than each
// registering their own.
var fake = &fakeSeeder{}
func init() {
seed.RegisterSeeder(fake)
}
// TestRegistersUnderContractMigrationForApp is this package's core claim:
// that createOrderSchema is reachable through contract/migration's
// package-level Snapshot, the only registry a third-party module can
// register against. It does not confirm any host actually calls Snapshot
// today - see the report.
func TestRegistersUnderContractMigrationForApp(t *testing.T) {
entries := contractmigration.Snapshot()
entry, ok := entries[AppCode+"-"+version]
if !ok {
t.Fatalf("no entry for %s-%s; registered: %v", AppCode, version, keysOf(entries))
}
if entry.AppCode != AppCode {
t.Errorf("Entry.AppCode = %q, want %q", entry.AppCode, AppCode)
}
}
func keysOf(m map[string]contractmigration.Entry) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// TestMigrationCreatesTablesSeedsMenusAndRecordsItself runs the registered
// migration function directly against a fresh sqlite database - standing in
// for the host's execution engine, which (see the report) does not exist
// yet for an externally-registered app. It is the closest thing to an
// end-to-end run this example can do without wt-shim's cooperation.
func TestMigrationCreatesTablesSeedsMenusAndRecordsItself(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
// sys_migration itself is created by the framework's own first
// migration (go-admin's cmd/migrate/migration/version/*_tables.go),
// which by the time any app's migration runs has always already run -
// simulate that precondition rather than app-order's own migration
// creating a table it does not own.
if err := db.AutoMigrate(&contractmodels.Migration{}); err != nil {
t.Fatalf("automigrate sys_migration: %v", err)
}
entries := contractmigration.Snapshot()
entry, ok := entries[AppCode+"-"+version]
if !ok {
t.Fatalf("no entry for %s-%s", AppCode, version)
}
if err := entry.Fn(db, AppCode+"-"+version); err != nil {
t.Fatalf("running the registered migration: %v", err)
}
if !db.Migrator().HasTable(&models.Order{}) {
t.Error("app_order was not created")
}
if !db.Migrator().HasTable(&models.OrderItem{}) {
t.Error("app_order_item was not created")
}
var migrationRow contractmodels.Migration
if err := db.Where("version = ?", AppCode+"-"+version).First(&migrationRow).Error; err != nil {
t.Fatalf("sys_migration row: %v", err)
}
if migrationRow.AppCode != AppCode {
t.Errorf("sys_migration.app_code = %q, want %q", migrationRow.AppCode, AppCode)
}
if fake.appCode != AppCode {
t.Errorf("Seeder saw appCode %q, want %q", fake.appCode, AppCode)
}
assertMenuGraphIsConsistent(t, fake.menus, fake.apis)
}
// assertMenuGraphIsConsistent checks the two rules that would otherwise
// only surface as a broken admin UI at install time: every Parent
// reference resolves to a Code in the same batch, and the frontend's
// apps/<code>/ convention for a packaged page's Component (documented on
// MenuSpec.Component, enforced by nothing - see the report) is actually
// followed.
func assertMenuGraphIsConsistent(t *testing.T, menus []seed.MenuSpec, apis []seed.ApiSpec) {
t.Helper()
codes := make(map[string]seed.MenuSpec, len(menus))
for _, m := range menus {
codes[m.Code] = m
}
apiCodes := make(map[string]bool, len(apis))
for _, a := range apis {
apiCodes[a.Code] = true
}
for _, m := range menus {
if m.Parent != "" {
if _, ok := codes[m.Parent]; !ok {
t.Errorf("menu %q has Parent %q, which is not a Code in this batch", m.Code, m.Parent)
}
}
for _, ac := range m.ApiCodes {
if !apiCodes[ac] {
t.Errorf("menu %q references ApiCode %q, which is not in this batch's apis", m.Code, ac)
}
}
if m.Kind == contractmodels.Menu && m.Component != "" {
if !strings.HasPrefix(m.Component, "apps/"+AppCode+"/") {
t.Errorf("menu %q has Component %q, want it to start with apps/%s/", m.Code, m.Component, AppCode)
}
}
}
}
+52
View File
@@ -0,0 +1,52 @@
// Package models holds app-order's two GORM row models.
package models
import (
contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
)
// The two values Order.Status can hold. Kept as narrow strings rather than
// an int enum to match sys_role.data_scope's own convention in core, and to
// leave room for a future status without a schema change.
const (
StatusPending = "1" // awaiting payment
StatusPaid = "2" // paid; set only by a successful Pay
)
// orderTable is passed to actions.Permission and repeated as
// Order.TableName's return value. It is not literally the word "order":
// that is a reserved SQL keyword, and actions.Permission builds its WHERE
// clause by string-concatenating tableName straight into raw SQL
// (`tableName+".create_by = ?"`, see permission.go) with no quoting at all.
// A table named exactly "order" would make every data-scope query a syntax
// error on MySQL's default (non-ANSI-quotes) mode. This is not something
// core enforces or even mentions - Permission's tableName parameter is an
// opaque string as far as it is concerned - so avoiding reserved words is
// entirely on the caller.
const orderTable = "app_order"
// Order is one customer order. ControlBy is required, not decorative:
// actions.Permission's data-scope SQL joins against create_by, so an Order
// without it would make every data-scope rule silently match nothing.
type Order struct {
contractmodels.Model
OrderNo string `json:"orderNo" gorm:"type:varchar(64);uniqueIndex;comment:order number"`
UserId int `json:"userId" gorm:"index;comment:buyer user id"`
Status string `json:"status" gorm:"type:varchar(4);index;comment:order status: 1 pending, 2 paid"`
TotalCents int64 `json:"totalCents" gorm:"comment:total amount in cents, sum of item price*quantity at creation time"`
// Items is populated by Preload; it is never set by Order's own migrator
// column set (OrderItem.OrderId is the foreign key, not a column here).
Items []OrderItem `json:"items,omitempty" gorm:"foreignKey:OrderId"`
contractmodels.ControlBy
contractmodels.ModelTime
}
// TableName pins the row model to app_order regardless of any global
// singular/plural table naming strategy the host configures. See orderTable
// above for why this is not simply "order".
func (Order) TableName() string {
return orderTable
}
+29
View File
@@ -0,0 +1,29 @@
package models
// orderItemTable mirrors orderTable's naming rationale: not a reserved word,
// and namespaced under app_ so a host scanning its schema can tell at a
// glance which tables an installed app owns.
const orderItemTable = "app_order_item"
// OrderItem is one line item of an Order. It carries no ControlBy of its
// own: data-scope is enforced once, on the parent Order, and an item is
// never queried on its own outside that parent (see service.Order.Get's
// Preload).
//
// OrderId+ProductName is unique on purpose, not just to have some index: it
// is what OrderService_test.go's mid-transaction-failure test relies on to
// force a real constraint violation after the parent Order row has already
// been inserted in the same transaction, proving the rollback actually
// undoes both writes rather than leaving the Order behind.
type OrderItem struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement;comment:primary key"`
OrderId int `json:"orderId" gorm:"uniqueIndex:uk_app_order_item_product;comment:parent order id"`
ProductName string `json:"productName" gorm:"type:varchar(255);uniqueIndex:uk_app_order_item_product;comment:product name"`
Quantity int `json:"quantity" gorm:"comment:quantity"`
PriceCents int64 `json:"priceCents" gorm:"comment:unit price in cents"`
}
// TableName pins the row model to app_order_item; see orderItemTable.
func (OrderItem) TableName() string {
return orderItemTable
}
+67
View File
@@ -0,0 +1,67 @@
// Package router wires app-order's four routes onto a host's gin engine.
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/actions"
coreruntime "github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
"github.com/go-admin-team/example-app-order/apis"
)
// RegisterRouter mounts app-order's routes under v1.
//
// Its signature - (v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware)
// - is not app-order's own invention: it is the exact shape every in-tree
// go-admin app router package already registers into its own routerCheckRole
// slice (see app/demo/router/demo_product.go), so a host installs this
// exactly where it installs its own app/*/router packages: one file under
// cmd/api/ that imports this package and appends RegisterRouter (adjusted to
// the host's own registration slice's calling convention) - see
// cmd/api/demo.go for the pattern.
//
// authMiddleware is taken as an explicit parameter rather than fetched
// through sdk.Runtime.GetHandlerFunc(coreruntime.JwtTokenCheck). As of this
// writing the reference host (go-admin's common/middleware/init.go) registers
// that key with an unbound method expression -
// sdk.Runtime.SetMiddleware(JwtTokenCheck, (*jwt.GinJWTMiddleware).MiddlewareFunc)
// - which is exactly the shape GetHandlerFunc's own doc comment warns
// against: the stored value's type is func(*jwt.GinJWTMiddleware)
// gin.HandlerFunc, not gin.HandlerFunc, so GetHandlerFunc's type assertion
// fails and it reports ok=false every time, for every caller, not just this
// one. Taking authMiddleware directly sidesteps that live bug and matches
// what every in-tree app already does.
func RegisterRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
roleCheck, ok := sdk.Runtime.GetHandlerFunc(coreruntime.RoleCheck)
if !ok {
// A host that has not wired up RoleCheck has not wired up Casbin
// authorization at all. Registering these routes without it would
// silently serve every order to every authenticated caller
// regardless of role - fail loud at startup instead, the same way
// PermissionAction fails loud (Abort, not c.Next) when its own
// database lookup errors. See contract/actions.PermissionAction's
// doc comment for the same reasoning applied to data-scope instead
// of role.
panic("app-order: host has not registered core's " + coreruntime.RoleCheck +
" middleware (sdk.Runtime.SetMiddleware); refusing to mount unauthorized order routes")
}
e := apis.Order{}
r := v1.Group("/order").
Use(authMiddleware.MiddlewareFunc()).
Use(roleCheck)
{
// actions.PermissionAction is imported directly from core - a plain
// function, not something fetched through sdk.Runtime - because
// unlike RoleCheck's Casbin policy tables (host-owned; see
// contract/actions's package doc), the data-scope machinery it
// installs has no host-specific state at all.
r.GET("", actions.PermissionAction(), e.GetPage)
r.GET("/:id", actions.PermissionAction(), e.Get)
r.POST("", e.Create)
r.PUT("/:id/pay", actions.PermissionAction(), e.Pay)
}
}
+80
View File
@@ -0,0 +1,80 @@
package router
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"github.com/go-admin-team/go-admin-core/v2/sdk"
coreruntime "github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
)
func testAuthMiddleware(t *testing.T) *jwt.GinJWTMiddleware {
t.Helper()
mw, err := jwt.New(&jwt.GinJWTMiddleware{
Realm: "test",
Key: []byte("test-signing-key"),
SigningAlgorithm: "HS256",
Timeout: 0,
TokenLookup: "header: Authorization",
TokenHeadName: "Bearer",
})
if err != nil {
t.Fatalf("building a test JWT middleware: %v", err)
}
return mw
}
// sdk.Runtime is a single process-wide instance (see its doc comment) with no
// way to unregister a middleware key, so this test needs RoleCheck to be
// unset - which makes it look order-dependent. It is not: the test that does
// register RoleCheck puts it back in a t.Cleanup, and the guard below turns a
// wrong order into a loud failure rather than a silent pass. Verified with
// `go test -shuffle=<seed>` on seeds that run the two in either order.
func TestRegisterRouterPanicsWithoutHostRoleCheck(t *testing.T) {
if _, ok := sdk.Runtime.GetHandlerFunc(coreruntime.RoleCheck); ok {
t.Fatal("RoleCheck is already registered; this test must run before any test that registers it")
}
defer func() {
if recover() == nil {
t.Fatal("RegisterRouter did not panic with no host RoleCheck middleware registered")
}
}()
gin.SetMode(gin.TestMode)
r := gin.New()
v1 := r.Group("/api/v1")
RegisterRouter(v1, testAuthMiddleware(t))
}
func TestRegisterRouterMountsRoutesOnceRoleCheckIsRegistered(t *testing.T) {
sdk.Runtime.SetMiddleware(coreruntime.RoleCheck, gin.HandlerFunc(func(c *gin.Context) { c.Next() }))
// sdk.Runtime has no way to unregister a middleware key (SetMiddleware
// only ever adds or overwrites - see its doc comment), so restore the
// "as far as GetHandlerFunc is concerned, unregistered" state other
// tests in this package depend on: a nil interface{} fails
// GetHandlerFunc's gin.HandlerFunc type assertion the same way a never-
// set key does. Needed for `go test -count=2` and similar re-runs
// within one process, not for a single run.
t.Cleanup(func() { sdk.Runtime.SetMiddleware(coreruntime.RoleCheck, nil) })
gin.SetMode(gin.TestMode)
r := gin.New()
v1 := r.Group("/api/v1")
RegisterRouter(v1, testAuthMiddleware(t))
// A route that exists returns something other than 404, even if the
// JWT/Casbin/PermissionAction chain in front of it then rejects the
// unauthenticated test request - proving RegisterRouter actually wired
// the route up is the point, not exercising the auth chain itself.
req := httptest.NewRequest(http.MethodGet, "/api/v1/order", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code == http.StatusNotFound {
t.Errorf("GET /api/v1/order was not registered (404)")
}
}
+57
View File
@@ -0,0 +1,57 @@
// Package dto holds app-order's request-binding types.
//
// None of them implement core's dto.Index / dto.Control, and none of them
// define their own Bind method: those interfaces (and the Bind method they
// require) exist so the framework's generic CRUD Actions
// (Create/Delete/Index/Update/ViewAction) can bind a request without
// knowing its concrete type - Action itself calls req.Bind(c). app-order's
// handlers (apis/order.go) call api.Api.Bind directly on the raw struct
// instead, exactly as go-admin's own hand-written handlers do (see
// app/admin/apis/sys_post.go and its service/dto/sys_post.go, which is the
// same shape: plain structs, no Bind method), so a Bind method here would
// never be called by anything and would only mislead a reader into thinking
// it is.
//
// What these types do reuse is dto.Pagination (for the list request's page
// index/size) and the `search` struct-tag convention dto.MakeCondition
// resolves; both are plain data shapes, not an interface a hand-written
// handler would otherwise have to reimplement.
package dto
import (
contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
)
// OrderItemReq is one line item in a create-order request.
type OrderItemReq struct {
ProductName string `json:"productName" validate:"required"`
Quantity int `json:"quantity" validate:"gte=1"`
PriceCents int64 `json:"priceCents" validate:"gte=0"`
}
// OrderCreateReq is the create-order request body.
type OrderCreateReq struct {
Items []OrderItemReq `json:"items" validate:"required"`
}
// OrderSearchReq is the list-order query.
//
// contractdto.MakeCondition reads q's `search` tags through
// reflect.TypeOf(q).NumField(), which is only valid for a struct Kind - a
// pointer panics rather than returning an error (see
// service/order.go:GetPage, which is careful to pass *req, not req). That
// distinction is not documented on MakeCondition's exported doc comment.
// Pagination `search:"-"` here follows the same convention the framework's
// own generic DTOs use to keep Pagination's two fields out of the WHERE
// clause the tags on Status/OrderNo build.
type OrderSearchReq struct {
contractdto.Pagination `search:"-"`
Status string `form:"status" search:"type:exact;column:status;table:app_order"`
OrderNo string `form:"orderNo" search:"type:exact;column:order_no;table:app_order"`
}
// OrderIdReq binds a single :id, for a detail lookup or a Pay request.
type OrderIdReq struct {
Id int `uri:"id" validate:"required"`
}
+176
View File
@@ -0,0 +1,176 @@
// Package service is app-order's business logic: everything the PRD asked
// this example to prove out by hand rather than by wiring up core's generic
// CRUD Actions (Create/Delete/Index/Update/ViewAction stay in go-admin, not
// in core - see contract/actions's package doc for why). An order's write
// path is a cross-table transaction and its one state change needs a
// concurrency guard neither generic Action was ever built for, which is
// exactly the class of logic real third-party apps almost always have.
package service
import (
"errors"
"fmt"
"time"
"gorm.io/gorm"
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/actions"
contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"github.com/go-admin-team/example-app-order/models"
orderdto "github.com/go-admin-team/example-app-order/service/dto"
)
// ErrOrderEmpty is returned by Create when the request has no line items.
var ErrOrderEmpty = errors.New("app-order: an order must have at least one item")
// ErrOrderNotPending is returned by Pay when the order could not be paid:
// it does not exist, it is not in models.StatusPending, or the caller's
// data scope does not include it. Deliberately one error for all three -
// see Pay's doc comment for why collapsing them is the fail-closed choice,
// not a shortcut.
var ErrOrderNotPending = errors.New("app-order: order is not awaiting payment")
// Order is app-order's hand-written service. It embeds core's
// sdk/service.Service purely for the Orm/Log/Cache fields every
// api.Api.MakeService caller already wires up the same way go-admin's own
// hand-written services do (see app/admin/apis/sys_post.go) - not because
// anything here calls a method Service defines.
type Order struct {
service.Service
}
// Create places a new order. The order row and every item row commit
// together: db.Transaction's closure form is what makes that true even
// across a panic (it recovers, rolls back, and re-panics - see gorm's own
// Transaction implementation), unlike the hand-rolled Begin/defer pattern
// go-admin's sys_role.go/sys_dept.go/sys_menu.go/sys_tables.go use, which
// commits a half-written transaction on panic, never opens a real
// transaction under sqlite, and reads a single global DB handle regardless
// of which tenant the request is for.
func (e *Order) Create(req *orderdto.OrderCreateReq, userId int) (*models.Order, error) {
if len(req.Items) == 0 {
return nil, ErrOrderEmpty
}
items := make([]models.OrderItem, 0, len(req.Items))
var total int64
for _, it := range req.Items {
total += it.PriceCents * int64(it.Quantity)
items = append(items, models.OrderItem{
ProductName: it.ProductName,
Quantity: it.Quantity,
PriceCents: it.PriceCents,
})
}
order := &models.Order{
OrderNo: generateOrderNo(),
UserId: userId,
Status: models.StatusPending,
TotalCents: total,
}
order.SetCreateBy(userId)
order.SetUpdateBy(userId)
err := e.Orm.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(order).Error; err != nil {
return err
}
for i := range items {
items[i].OrderId = order.Id
}
// A single batch Create, not one Create per item: on the unique
// (order_id, product_name) violation the test suite exercises, the
// whole statement fails, and nothing about this order - not the
// order row created two lines above, not any item before the
// duplicate - survives the rollback.
if err := tx.Create(&items).Error; err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
order.Items = items
return order, nil
}
// Get loads one order, scoped to p's data permission, with its items.
func (e *Order) Get(id int, p *actions.DataPermission, out *models.Order) error {
return e.Orm.
Scopes(actions.Permission(orderTableName, p)).
Preload("Items").
Where("id = ?", id).
First(out).Error
}
// GetPage lists orders visible to p's data scope, filtered by req's search
// tags and paginated. The Find-then-Count-on-the-same-chain shape mirrors
// go-admin's own common/actions.IndexAction: Limit(-1).Offset(-1) undoes
// Paginate's LIMIT/OFFSET before the count runs, on the same *gorm.DB
// session, so the WHERE clause built by MakeCondition and Permission is not
// re-resolved a second time.
func (e *Order) GetPage(req *orderdto.OrderSearchReq, p *actions.DataPermission, list *[]models.Order) (int64, error) {
var count int64
// *req, not req: contractdto.MakeCondition resolves search tags through
// reflect.TypeOf(q).NumField(), which panics on a pointer. See
// service/dto/order.go's doc comment on OrderSearchReq.
err := e.Orm.Model(&models.Order{}).
Scopes(
contractdto.MakeCondition(*req),
contractdto.Paginate(req.GetPageSize(), req.GetPageIndex()),
actions.Permission(orderTableName, p),
).
Find(list).Limit(-1).Offset(-1).
Count(&count).Error
return count, err
}
// Pay transitions a pending order to paid.
//
// The concurrency guard is the WHERE clause, not an application-level lock:
// two concurrent payment attempts against the same order both issue this
// UPDATE, but only the one that actually flips a row from pending to paid
// sees RowsAffected == 1 - the loser's WHERE matches nothing (the row is
// already 'paid' by the time its UPDATE runs) and sees 0, becoming
// ErrOrderNotPending rather than a second, silently-accepted payment.
//
// The same RowsAffected==0 outcome also covers "no such order" and "this
// order exists but is outside p's data scope" - actions.Permission's own
// scope is one of the Scopes below, so a caller paying an order they
// cannot see gets the identical error a caller paying an already-paid
// order gets. That collapse is deliberate: a distinguishable "exists but
// not yours" response would leak which order ids exist to a caller who
// should not be able to tell.
func (e *Order) Pay(id int, p *actions.DataPermission) error {
result := e.Orm.
Scopes(actions.Permission(orderTableName, p)).
Model(&models.Order{}).
Where("id = ? AND status = ?", id, models.StatusPending).
Updates(map[string]interface{}{"status": models.StatusPaid})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrOrderNotPending
}
return nil
}
// orderTableName is models.Order{}.TableName(), repeated here as a plain
// string because actions.Permission takes the table name as a bare string,
// not a model - see models/order.go's orderTable doc comment for why it is
// not literally "order".
const orderTableName = "app_order"
// generateOrderNo is a placeholder good enough for this example: real
// production code would want a collision-proof id source (a sequence, a
// snowflake id, or similar). Nothing about the transaction or the
// concurrency guard above depends on how this string is built.
func generateOrderNo() string {
return fmt.Sprintf("ORD%d", time.Now().UnixNano())
}
+352
View File
@@ -0,0 +1,352 @@
package service
import (
"errors"
"sync"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/actions"
coreservice "github.com/go-admin-team/go-admin-core/v2/sdk/service"
"github.com/go-admin-team/example-app-order/models"
orderdto "github.com/go-admin-team/example-app-order/service/dto"
)
// testDB returns a fresh, isolated in-memory sqlite database with
// app_order/app_order_item created, following the same
// glebarez/sqlite-and-no-build-tag setup core's own contract package tests
// use (see sdk/contract/actions/permission_test.go and
// sdk/contract/seed/seed_test.go).
func testDB(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(&models.Order{}, &models.OrderItem{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
return db
}
// enableDataPermission flips on the switch actions.Permission checks before
// applying any data-scope filtering at all, restoring the previous value
// after the test - the same pattern
// sdk/contract/actions/permission_test.go uses.
func enableDataPermission(t *testing.T) {
t.Helper()
previous := config.ApplicationConfig.EnableDP
config.ApplicationConfig.EnableDP = true
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
}
func newOrderService(t *testing.T, db *gorm.DB) *Order {
t.Helper()
return &Order{Service: coreservice.Service{Orm: db}}
}
// -- cross-table transaction ------------------------------------------------
func TestCreate_CommitsOrderAndItemsTogether(t *testing.T) {
db := testDB(t)
s := newOrderService(t, db)
req := &orderdto.OrderCreateReq{Items: []orderdto.OrderItemReq{
{ProductName: "widget", Quantity: 2, PriceCents: 500},
{ProductName: "gadget", Quantity: 1, PriceCents: 1200},
}}
order, err := s.Create(req, 42)
if err != nil {
t.Fatalf("Create: %v", err)
}
if order.TotalCents != 2*500+1200 {
t.Errorf("TotalCents = %d, want %d", order.TotalCents, 2*500+1200)
}
if order.Status != models.StatusPending {
t.Errorf("Status = %q, want pending", order.Status)
}
if order.CreateBy != 42 || order.UpdateBy != 42 {
t.Errorf("CreateBy/UpdateBy = %d/%d, want 42/42", order.CreateBy, order.UpdateBy)
}
var itemCount int64
db.Model(&models.OrderItem{}).Where("order_id = ?", order.Id).Count(&itemCount)
if itemCount != 2 {
t.Errorf("persisted %d items, want 2", itemCount)
}
}
func TestCreate_EmptyItemsReturnsErrorAndWritesNothing(t *testing.T) {
db := testDB(t)
s := newOrderService(t, db)
_, err := s.Create(&orderdto.OrderCreateReq{}, 1)
if !errors.Is(err, ErrOrderEmpty) {
t.Fatalf("got error %v, want ErrOrderEmpty", err)
}
var count int64
db.Model(&models.Order{}).Count(&count)
if count != 0 {
t.Errorf("an order was written despite the empty-items error")
}
}
// A mid-transaction failure must roll back everything written before it in
// the same transaction, including the parent row. The duplicate product
// name is what forces a real, DB-enforced constraint violation on the
// second item's insert - see OrderItem's doc comment.
func TestCreate_MidTransactionFailureRollsBackEverything(t *testing.T) {
db := testDB(t)
s := newOrderService(t, db)
req := &orderdto.OrderCreateReq{Items: []orderdto.OrderItemReq{
{ProductName: "widget", Quantity: 1, PriceCents: 100},
{ProductName: "widget", Quantity: 1, PriceCents: 100}, // duplicate: violates uk_app_order_item_product
}}
_, err := s.Create(req, 1)
if err == nil {
t.Fatal("Create succeeded despite a duplicate line item; the unique constraint did not fire")
}
var orderCount, itemCount int64
db.Model(&models.Order{}).Count(&orderCount)
db.Model(&models.OrderItem{}).Count(&itemCount)
if orderCount != 0 {
t.Errorf("the order row survived the rollback: %d rows in app_order", orderCount)
}
if itemCount != 0 {
t.Errorf("an item row survived the rollback: %d rows in app_order_item", itemCount)
}
}
// A panic partway through the transaction must roll back exactly as
// cleanly as a returned error does. This is not testing app-order's own
// code so much as the primitive Create is built on: gorm's db.Transaction
// recovers a panic, rolls back, and re-panics, which is what makes it safe
// to use in place of go-admin's hand-rolled Begin/defer pattern (see
// Create's doc comment) - a pattern that, on a panic, commits whatever the
// transaction had written so far instead of undoing it.
func TestCreate_PanicInsideTransactionRollsBackEverything(t *testing.T) {
db := testDB(t)
func() {
defer func() {
if recover() == nil {
t.Fatal("db.Transaction did not propagate the panic")
}
}()
_ = db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&models.Order{OrderNo: "panic-test", Status: models.StatusPending}).Error; err != nil {
t.Fatalf("Create inside transaction: %v", err)
}
panic("simulated failure after a partial write")
})
}()
var count int64
db.Model(&models.Order{}).Count(&count)
if count != 0 {
t.Errorf("the order row survived a panic mid-transaction: %d rows in app_order", count)
}
}
// -- status transition / concurrency guard ----------------------------------
func createPendingOrder(t *testing.T, s *Order, userId int) *models.Order {
t.Helper()
order, err := s.Create(&orderdto.OrderCreateReq{Items: []orderdto.OrderItemReq{
{ProductName: "widget", Quantity: 1, PriceCents: 100},
}}, userId)
if err != nil {
t.Fatalf("Create: %v", err)
}
return order
}
func TestPay_TransitionsPendingToPaid(t *testing.T) {
db := testDB(t)
s := newOrderService(t, db)
order := createPendingOrder(t, s, 1)
if err := s.Pay(order.Id, &actions.DataPermission{DataScope: actions.DataScopeAll}); err != nil {
t.Fatalf("Pay: %v", err)
}
var got models.Order
db.First(&got, order.Id)
if got.Status != models.StatusPaid {
t.Errorf("Status = %q, want paid", got.Status)
}
}
func TestPay_AlreadyPaidReturnsErrOrderNotPending(t *testing.T) {
db := testDB(t)
s := newOrderService(t, db)
order := createPendingOrder(t, s, 1)
all := &actions.DataPermission{DataScope: actions.DataScopeAll}
if err := s.Pay(order.Id, all); err != nil {
t.Fatalf("first Pay: %v", err)
}
if err := s.Pay(order.Id, all); !errors.Is(err, ErrOrderNotPending) {
t.Fatalf("second Pay returned %v, want ErrOrderNotPending", err)
}
}
// Two concurrent payment attempts against the same pending order: exactly
// one must succeed. MaxOpenConns(1) is set on the underlying *sql.DB so the
// two goroutines' UPDATEs serialize the way two independent connections
// would under MySQL, rather than one of them failing outright with
// SQLITE_BUSY - sqlite is a single-writer database with no useful
// concurrency of its own to exercise here. What the test actually verifies
// is unaffected by that: the guard is the UPDATE ... WHERE status =
// 'pending' clause and the RowsAffected check on its result (Pay's doc
// comment), and that logic runs once per goroutine regardless of how the
// pool schedules the two connections.
func TestPay_ConcurrentPaymentsOnlyOneSucceeds(t *testing.T) {
db := testDB(t)
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("DB(): %v", err)
}
sqlDB.SetMaxOpenConns(1)
s := newOrderService(t, db)
order := createPendingOrder(t, s, 1)
all := &actions.DataPermission{DataScope: actions.DataScopeAll}
var wg sync.WaitGroup
errs := make([]error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
errs[i] = s.Pay(order.Id, all)
}(i)
}
wg.Wait()
successes, failures := 0, 0
for _, err := range errs {
switch {
case err == nil:
successes++
case errors.Is(err, ErrOrderNotPending):
failures++
default:
t.Fatalf("unexpected error from a concurrent Pay: %v", err)
}
}
if successes != 1 || failures != 1 {
t.Fatalf("got %d successes and %d failures, want exactly 1 and 1", successes, failures)
}
}
// -- data permission ---------------------------------------------------------
func TestGetPage_SelfScopeOnlySeesOwnOrders(t *testing.T) {
enableDataPermission(t)
db := testDB(t)
s := newOrderService(t, db)
createPendingOrder(t, s, 1) // belongs to user 1
createPendingOrder(t, s, 2) // belongs to user 2
var list []models.Order
count, err := s.GetPage(&orderdto.OrderSearchReq{}, &actions.DataPermission{
DataScope: actions.DataScopeSelf,
UserId: 1,
}, &list)
if err != nil {
t.Fatalf("GetPage: %v", err)
}
if count != 1 || len(list) != 1 {
t.Fatalf("got %d orders, want exactly the 1 belonging to user 1", count)
}
if list[0].UserId != 1 {
t.Errorf("returned order belongs to user %d, not the caller", list[0].UserId)
}
}
func TestGetPage_AllScopeSeesEveryOrder(t *testing.T) {
enableDataPermission(t)
db := testDB(t)
s := newOrderService(t, db)
createPendingOrder(t, s, 1)
createPendingOrder(t, s, 2)
var list []models.Order
count, err := s.GetPage(&orderdto.OrderSearchReq{}, &actions.DataPermission{DataScope: actions.DataScopeAll}, &list)
if err != nil {
t.Fatalf("GetPage: %v", err)
}
if count != 2 {
t.Fatalf("got %d orders, want 2", count)
}
}
// An invalid/unrecognized data_scope must fail closed - match nothing -
// never fall back to "see everything". This is core's own documented
// contract (contract/actions.Permission's default case), exercised here
// against app-order's own table to confirm the fail-closed behaviour
// actually reaches a hand-written Service's query, not just core's own
// unit tests.
func TestGetPage_InvalidScopeSeesNothing(t *testing.T) {
enableDataPermission(t)
db := testDB(t)
s := newOrderService(t, db)
createPendingOrder(t, s, 1)
createPendingOrder(t, s, 2)
var list []models.Order
count, err := s.GetPage(&orderdto.OrderSearchReq{}, &actions.DataPermission{DataScope: "not-a-real-scope"}, &list)
if err != nil {
t.Fatalf("GetPage: %v", err)
}
if count != 0 || len(list) != 0 {
t.Fatalf("an invalid data scope returned %d orders, want 0 (fail closed)", count)
}
}
func TestGet_ReturnsOrderWithItemsPreloaded(t *testing.T) {
enableDataPermission(t)
db := testDB(t)
s := newOrderService(t, db)
created := createPendingOrder(t, s, 1)
var got models.Order
err := s.Get(created.Id, &actions.DataPermission{DataScope: actions.DataScopeSelf, UserId: 1}, &got)
if err != nil {
t.Fatalf("Get: %v", err)
}
if len(got.Items) != 1 {
t.Fatalf("got %d items, want the 1 created with the order", len(got.Items))
}
if got.Items[0].ProductName != "widget" {
t.Errorf("item ProductName = %q, want widget", got.Items[0].ProductName)
}
}
func TestGet_ScopedOutOrderReportsNotFoundNotForbidden(t *testing.T) {
enableDataPermission(t)
db := testDB(t)
s := newOrderService(t, db)
other := createPendingOrder(t, s, 2)
var got models.Order
err := s.Get(other.Id, &actions.DataPermission{DataScope: actions.DataScopeSelf, UserId: 1}, &got)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("Get on another user's order returned %v, want gorm.ErrRecordNotFound", err)
}
}
+2 -2
View File
@@ -11,7 +11,7 @@ require (
github.com/casbin/casbin/v3 v3.8.1
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/go-admin-team/go-admin-core/v2 v2.4.1
github.com/go-admin-team/go-admin-core/v2 v2.5.0
github.com/google/uuid v1.6.0
github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible
github.com/mssola/user_agent v0.6.0
@@ -32,7 +32,6 @@ require (
gorm.io/driver/sqlite v1.6.0
gorm.io/driver/sqlserver v1.6.4
gorm.io/gorm v1.31.2
gorm.io/plugin/soft_delete v1.2.1
)
require (
@@ -142,6 +141,7 @@ require (
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // 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
+2 -2
View File
@@ -145,8 +145,8 @@ github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-admin-team/go-admin-core/v2 v2.4.1 h1:69QprBVMcQzjVP0UksCwi//A0qh8gwcIHcwHmABPp2U=
github.com/go-admin-team/go-admin-core/v2 v2.4.1/go.mod h1:YiJr2+vqC9qV5AoGeL+1W55h3XZ99CB5xWnP3Wo8c5g=
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-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=