mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-21 10:13:01 +00:00
No generic CRUD action anywhere: real business - a cross-table order placement, a payment transition - is what the contract surface has to carry, and the actions cover only the single-table case that a real application outgrows immediately. The transaction is Orm.Transaction(), not the Begin/defer shape that app/admin/service/sys_role.go and three other files use. That shape commits a half-written transaction when the body panics, because the deferred check reads err, which a panic leaves nil. The payment transition guards concurrency through the update itself - WHERE status = 'pending' plus RowsAffected - rather than a read followed by a write. The tests cover both rollback paths, because they fail differently: a mid-transaction error returns, a panic unwinds - and the second is what tells Orm.Transaction() apart from the shape it replaces. The concurrency test pins the pool to one writer so sqlite's own single-writer semantics cannot stand in for the guard being tested. The data-scope tests assert the fail-closed direction too: an unrecognised scope must return no rows rather than every row. Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
177 lines
6.8 KiB
Go
177 lines
6.8 KiB
Go
// 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())
|
|
}
|