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
19 changed files with 1580 additions and 147 deletions
+5 -3
View File
@@ -25,9 +25,11 @@ func InitRouter() {
os.Exit(-1)
}
// the jwt middleware: shared instance InitMiddleware built at startup,
// not one built here per module (see common/middleware.GetAuthMiddleware).
authMiddleware := common.GetAuthMiddleware()
// the jwt middleware
authMiddleware, err := common.AuthInit()
if err != nil {
log.Fatalf("JWT Init Error, %s", err.Error())
}
// 注册系统路由
InitSysRouter(r, authMiddleware)
+5 -3
View File
@@ -33,9 +33,11 @@ func InitRouter() {
os.Exit(-1)
}
// the jwt middleware: shared instance InitMiddleware built at startup,
// not one built here per module (see common/middleware.GetAuthMiddleware).
authMiddleware := common.GetAuthMiddleware()
// the jwt middleware
authMiddleware, err := common.AuthInit()
if err != nil {
log.Fatalf("JWT Init Error, %s", err.Error())
}
// 注册业务路由
InitBusinessRouter(r, authMiddleware)
+4 -3
View File
@@ -26,9 +26,10 @@ func InitRouter() {
os.Exit(-1)
}
// the jwt middleware: shared instance InitMiddleware built at startup,
// not one built here per module (see common/middleware.GetAuthMiddleware).
authMiddleware := common.GetAuthMiddleware()
authMiddleware, err := common.AuthInit()
if err != nil {
log.Fatalf("JWT Init Error, %s", err.Error())
}
// 注册业务路由
initRouter(r, authMiddleware)
+4 -3
View File
@@ -25,9 +25,10 @@ func InitRouter() {
os.Exit(-1)
}
// the jwt middleware
// the jwt middleware: shared instance InitMiddleware built at startup,
// not one built here per module (see common/middleware.GetAuthMiddleware).
authMiddleware := common.GetAuthMiddleware()
authMiddleware, err := common.AuthInit()
if err != nil {
log.Fatalf("JWT Init Error, %s", err.Error())
}
// 注册业务路由
// TODO: 这里可存放业务路由,里边并无实际路由只有演示代码
+2 -29
View File
@@ -3,19 +3,11 @@ package middleware
import (
"time"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/common/middleware/handler"
)
// authMiddleware is the single JWT middleware instance the whole process
// shares. InitMiddleware builds it once, before any module registers its
// routes; GetAuthMiddleware is how a module gets it back instead of calling
// AuthInit itself and building another, functionally-equivalent-but-distinct
// instance.
var authMiddleware *jwt.GinJWTMiddleware
// AuthInit jwt验证new
func AuthInit() (*jwt.GinJWTMiddleware, error) {
timeout := time.Hour
@@ -41,23 +33,4 @@ func AuthInit() (*jwt.GinJWTMiddleware, error) {
TimeFunc: time.Now,
})
}
// GetAuthMiddleware returns the shared JWT middleware instance InitMiddleware
// built at startup. Application modules (app/admin, app/jobs, app/other,
// app/demo) call this instead of AuthInit so their router chains - which
// still need the instance itself for authMiddleware.MiddlewareFunc() and
// authMiddleware.LoginHandler, not just the bound closure registered under
// sdk.Runtime's JwtTokenCheck key - end up using the same instance the host
// registered, rather than one each.
//
// It fails loudly instead of returning nil: an InitRouter that runs before
// InitMiddleware has a real startup-ordering bug, not a case to paper over
// with a nil *jwt.GinJWTMiddleware that would panic much further down the
// call chain with a far less useful stack trace.
func GetAuthMiddleware() *jwt.GinJWTMiddleware {
if authMiddleware == nil {
log.Fatal("JWT middleware not initialized; InitMiddleware must run before any module's InitRouter")
}
return authMiddleware
}
}
+5 -29
View File
@@ -2,20 +2,15 @@ package middleware
import (
"github.com/gin-gonic/gin"
log "github.com/go-admin-team/go-admin-core/v2/logger"
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/runtime"
"go-admin/common/actions"
)
// These alias core's own constants (see sdk/runtime.GetHandlerFunc's contract
// doc, section 9) rather than redeclaring the same three strings, so a typo
// here can no longer split registration and lookup into two different keys
// that both happen to compile.
const (
JwtTokenCheck = runtime.JwtTokenCheck
RoleCheck = runtime.RoleCheck
PermissionCheck = runtime.PermissionCheck
JwtTokenCheck string = "JwtToken"
RoleCheck string = "AuthCheckRole"
PermissionCheck string = "PermissionAction"
)
func InitMiddleware(r *gin.Engine) {
@@ -34,26 +29,7 @@ func InitMiddleware(r *gin.Engine) {
r.Use(Secure)
// 链路追踪
//r.Use(middleware.Trace())
// Build the shared JWT middleware instance here, before any module
// registers routes (initRouter runs ahead of runStartupHooks, which is
// what invokes each module's InitRouter - see cmd/api/server.go). Doing
// it once here, instead of once per module via AuthInit, is what makes
// GetAuthMiddleware and sdk.Runtime.GetHandlerFunc(JwtTokenCheck) both
// resolve to a single, meaningful instance instead of "whichever module
// happened to initialize last".
//
// SetMiddleware must be given a bound closure (authMiddleware.MiddlewareFunc()),
// not the unbound method expression (*jwt.GinJWTMiddleware).MiddlewareFunc:
// the latter has no receiver bound to it, so GetHandlerFunc's type
// assertion to gin.HandlerFunc always fails for it.
var err error
authMiddleware, err = AuthInit()
if err != nil {
// A process with no JWT middleware must not start serving requests.
log.Fatalf("JWT Init Error, %s", err.Error())
}
sdk.Runtime.SetMiddleware(JwtTokenCheck, authMiddleware.MiddlewareFunc())
sdk.Runtime.SetMiddleware(JwtTokenCheck, (*jwt.GinJWTMiddleware).MiddlewareFunc)
sdk.Runtime.SetMiddleware(RoleCheck, AuthCheckRole())
sdk.Runtime.SetMiddleware(PermissionCheck, actions.PermissionAction())
}
-77
View File
@@ -1,77 +0,0 @@
package middleware
import (
"testing"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
)
// freshRuntime hands the test its own Runtime and puts the old one back, the
// same pattern cmd/api/server_test.go uses: sdk.Runtime is a process-wide
// singleton, and a test that registers into it would otherwise leak state
// into every other test in the binary.
func freshRuntime(t *testing.T) {
t.Helper()
previous := sdk.Runtime
t.Cleanup(func() { sdk.Runtime = previous })
sdk.Runtime = runtime.NewConfig()
}
// TestInitMiddlewareRegistersUsableJwtHandlerFunc is the reverse proof for
// hoisting the JWT instance's construction into InitMiddleware:
// sdk.Runtime.GetHandlerFunc(JwtTokenCheck) must hand back ok=true and a
// non-nil gin.HandlerFunc, not just something GetMiddleware can return as an
// untyped interface{}.
//
// Before this change, InitMiddleware registered the unbound method
// expression (*jwt.GinJWTMiddleware).MiddlewareFunc under this key - a value
// with no receiver bound to it, which is not a gin.HandlerFunc no matter how
// a caller asserts its type. Reverting the registration below to that
// expression makes GetHandlerFunc report ok=false; it does not fail to
// compile, because (*jwt.GinJWTMiddleware).MiddlewareFunc has a well-formed,
// unrelated method-expression type that SetMiddleware's interface{} param
// happily accepts.
func TestInitMiddlewareRegistersUsableJwtHandlerFunc(t *testing.T) {
freshRuntime(t)
previousSecret := config.JwtConfig.Secret
config.JwtConfig.Secret = "test-secret-key"
t.Cleanup(func() { config.JwtConfig.Secret = previousSecret })
gin.SetMode(gin.TestMode)
InitMiddleware(gin.New())
h, ok := sdk.Runtime.GetHandlerFunc(JwtTokenCheck)
if !ok {
t.Fatal("GetHandlerFunc(JwtTokenCheck) reported ok=false after InitMiddleware ran")
}
if h == nil {
t.Fatal("GetHandlerFunc(JwtTokenCheck) reported ok=true but returned a nil handler")
}
}
// TestInitMiddlewareBuildsOneSharedJwtInstance locks down the fix for the
// four-instances problem: GetAuthMiddleware must return the very instance
// InitMiddleware built and handed to sdk.Runtime, not a lookalike built
// separately by whichever caller asks first.
func TestInitMiddlewareBuildsOneSharedJwtInstance(t *testing.T) {
freshRuntime(t)
previousSecret := config.JwtConfig.Secret
config.JwtConfig.Secret = "test-secret-key"
t.Cleanup(func() { config.JwtConfig.Secret = previousSecret })
gin.SetMode(gin.TestMode)
InitMiddleware(gin.New())
shared := GetAuthMiddleware()
if shared == nil {
t.Fatal("GetAuthMiddleware returned nil after InitMiddleware ran")
}
if shared != authMiddleware {
t.Error("GetAuthMiddleware did not return the package-level instance InitMiddleware built")
}
}
+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)
}
}