mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-24 19:17:43 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58105cb478 | ||
|
|
47af6f4306 | ||
|
|
0729624c2f | ||
|
|
b3a740ab2a | ||
|
|
adf617f5d0 | ||
|
|
049a20cd04 | ||
|
|
ffd82a6a10 | ||
|
|
dd8d89a990 | ||
|
|
2e5b23565e | ||
|
|
f4e3f04d30 | ||
|
|
954ebdc9eb | ||
|
|
2840010dfd | ||
|
|
b147d9b833 | ||
|
|
b3ecb81614 | ||
|
|
ce4581bb99 | ||
|
|
f406ca0160 | ||
|
|
4d6456a588 | ||
|
|
07ff92aa55 | ||
|
|
4156387eb9 |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user