feat: add orders
This commit is contained in:
25
backend/app/http/admin/orders.go
Normal file
25
backend/app/http/admin/orders.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"quyun/app/models"
|
||||
"quyun/app/requests"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type OrderListQuery struct {
|
||||
OrderNumber *string `query:"order_number"`
|
||||
UserID *int64 `query:"user_id"`
|
||||
}
|
||||
|
||||
// @provider
|
||||
type orders struct{}
|
||||
|
||||
// List users
|
||||
// @Router /v1/admin/orders [get]
|
||||
// @Bind pagination query
|
||||
// @Bind query query
|
||||
func (ctl *orders) List(ctx fiber.Ctx, pagination *requests.Pagination, query *OrderListQuery) (*requests.Pager, error) {
|
||||
cond := models.Orders.BuildConditionWithKey(query.OrderNumber, query.UserID)
|
||||
return models.Orders.List(ctx.Context(), pagination, cond)
|
||||
}
|
||||
@@ -18,6 +18,13 @@ func Provide(opts ...opt.Option) error {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := container.Container.Provide(func() (*orders, error) {
|
||||
obj := &orders{}
|
||||
|
||||
return obj, nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := container.Container.Provide(func() (*posts, error) {
|
||||
obj := &posts{}
|
||||
|
||||
@@ -27,12 +34,14 @@ func Provide(opts ...opt.Option) error {
|
||||
}
|
||||
if err := container.Container.Provide(func(
|
||||
medias *medias,
|
||||
orders *orders,
|
||||
posts *posts,
|
||||
uploads *uploads,
|
||||
users *users,
|
||||
) (contracts.HttpRoute, error) {
|
||||
obj := &Routes{
|
||||
medias: medias,
|
||||
orders: orders,
|
||||
posts: posts,
|
||||
uploads: uploads,
|
||||
users: users,
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
type Routes struct {
|
||||
log *log.Entry `inject:"false"`
|
||||
medias *medias
|
||||
orders *orders
|
||||
posts *posts
|
||||
uploads *uploads
|
||||
users *users
|
||||
@@ -38,6 +39,13 @@ func (r *Routes) Register(router fiber.Router) {
|
||||
Query[ListQuery]("query"),
|
||||
))
|
||||
|
||||
// 注册路由组: orders
|
||||
router.Get("/v1/admin/orders", DataFunc2(
|
||||
r.orders.List,
|
||||
Query[requests.Pagination]("pagination"),
|
||||
Query[OrderListQuery]("query"),
|
||||
))
|
||||
|
||||
// 注册路由组: posts
|
||||
router.Get("/v1/admin/posts", DataFunc2(
|
||||
r.posts.List,
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
var db *sql.DB
|
||||
var Medias *mediasModel
|
||||
var Orders *ordersModel
|
||||
var Posts *postsModel
|
||||
var Users *usersModel
|
||||
|
||||
@@ -16,6 +17,7 @@ var Users *usersModel
|
||||
type models struct {
|
||||
db *sql.DB
|
||||
medias *mediasModel
|
||||
orders *ordersModel
|
||||
posts *postsModel
|
||||
users *usersModel
|
||||
}
|
||||
@@ -23,6 +25,7 @@ type models struct {
|
||||
func (m *models) Prepare() error {
|
||||
db = m.db
|
||||
Medias = m.medias
|
||||
Orders = m.orders
|
||||
Posts = m.posts
|
||||
Users = m.users
|
||||
return nil
|
||||
|
||||
168
backend/app/models/orders.go
Normal file
168
backend/app/models/orders.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"quyun/app/requests"
|
||||
"quyun/database/fields"
|
||||
"quyun/database/schemas/public/model"
|
||||
"quyun/database/schemas/public/table"
|
||||
|
||||
. "github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// @provider
|
||||
type ordersModel struct {
|
||||
log *logrus.Entry `inject:"false"`
|
||||
}
|
||||
|
||||
func (m *ordersModel) Prepare() error {
|
||||
m.log = logrus.WithField("model", "ordersModel")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID returns an order by ID
|
||||
func (m *ordersModel) GetByID(ctx context.Context, id int64) (*model.Orders, error) {
|
||||
tbl := table.Orders
|
||||
|
||||
stmt := tbl.
|
||||
SELECT(tbl.AllColumns).
|
||||
WHERE(
|
||||
tbl.ID.EQ(Int64(id)),
|
||||
)
|
||||
m.log.Infof("sql: %s", stmt.DebugSql())
|
||||
|
||||
var order model.Orders
|
||||
err := stmt.QueryContext(ctx, db, &order)
|
||||
if err != nil {
|
||||
m.log.Errorf("error querying order by ID: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
// BuildConditionWithKey builds the WHERE clause for order queries
|
||||
func (m *ordersModel) BuildConditionWithKey(orderNumber *string, userID *int64) BoolExpression {
|
||||
tbl := table.Orders
|
||||
|
||||
cond := Bool(true)
|
||||
|
||||
if orderNumber != nil && *orderNumber != "" {
|
||||
cond = cond.AND(
|
||||
tbl.OrderNo.LIKE(String("%" + *orderNumber + "%")),
|
||||
)
|
||||
}
|
||||
|
||||
if userID != nil {
|
||||
cond = cond.AND(
|
||||
tbl.UserID.EQ(Int(*userID)),
|
||||
)
|
||||
}
|
||||
|
||||
return cond
|
||||
}
|
||||
|
||||
// countByCondition counts orders matching the given condition
|
||||
func (m *ordersModel) countByCondition(ctx context.Context, expr BoolExpression) (int64, error) {
|
||||
var cnt struct {
|
||||
Cnt int64
|
||||
}
|
||||
|
||||
tbl := table.Orders
|
||||
stmt := SELECT(COUNT(tbl.ID).AS("cnt")).FROM(tbl).WHERE(expr)
|
||||
m.log.Infof("sql: %s", stmt.DebugSql())
|
||||
|
||||
err := stmt.QueryContext(ctx, db, &cnt)
|
||||
if err != nil {
|
||||
m.log.Errorf("error counting orders: %v", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return cnt.Cnt, nil
|
||||
}
|
||||
|
||||
// List returns a paginated list of orders
|
||||
func (m *ordersModel) List(ctx context.Context, pagination *requests.Pagination, cond BoolExpression) (*requests.Pager, error) {
|
||||
pagination.Format()
|
||||
|
||||
tbl := table.Orders
|
||||
stmt := tbl.
|
||||
SELECT(tbl.AllColumns).
|
||||
WHERE(cond).
|
||||
ORDER_BY(tbl.ID.DESC()).
|
||||
LIMIT(pagination.Limit).
|
||||
OFFSET(pagination.Offset)
|
||||
m.log.Infof("sql: %s", stmt.DebugSql())
|
||||
|
||||
var orders []model.Orders = make([]model.Orders, 0)
|
||||
err := stmt.QueryContext(ctx, db, &orders)
|
||||
if err != nil {
|
||||
m.log.Errorf("error querying orders: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
count, err := m.countByCondition(ctx, cond)
|
||||
if err != nil {
|
||||
m.log.Errorf("error getting order count: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &requests.Pager{
|
||||
Items: orders,
|
||||
Total: count,
|
||||
Pagination: *pagination,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Create creates a new order
|
||||
func (m *ordersModel) Create(ctx context.Context, userId, postId int64) (*model.Orders, error) {
|
||||
post, err := Posts.GetByID(ctx, postId)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get post")
|
||||
}
|
||||
|
||||
model := &model.Orders{}
|
||||
model.CreatedAt = time.Now()
|
||||
model.UpdatedAt = time.Now()
|
||||
model.Status = fields.OrderStatusPending
|
||||
model.OrderNo = fmt.Sprintf("%s", time.Now().Format("20060102150405"))
|
||||
model.SubOrderNo = model.OrderNo
|
||||
model.UserID = userId
|
||||
model.PostID = postId
|
||||
model.Meta = fields.ToJson(fields.OrderMeta{})
|
||||
model.Price = post.Price
|
||||
model.Discount = post.Discount
|
||||
|
||||
tbl := table.Orders
|
||||
stmt := tbl.INSERT(tbl.MutableColumns).MODEL(model)
|
||||
m.log.Infof("sql: %s", stmt.DebugSql())
|
||||
|
||||
if _, err := stmt.ExecContext(ctx, db); err != nil {
|
||||
m.log.Errorf("error creating order: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// DeleteByID soft deletes an order by ID
|
||||
func (m *ordersModel) SetStatus(ctx context.Context, orderNo string, status fields.OrderStatus) error {
|
||||
tbl := table.Orders
|
||||
stmt := tbl.
|
||||
UPDATE(tbl.Status).
|
||||
SET(status).
|
||||
WHERE(
|
||||
tbl.OrderNo.EQ(String(orderNo)),
|
||||
)
|
||||
m.log.Infof("sql: %s", stmt.DebugSql())
|
||||
|
||||
if _, err := stmt.ExecContext(ctx, db); err != nil {
|
||||
m.log.Errorf("error set order status: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
47
backend/app/models/orders_test.go
Normal file
47
backend/app/models/orders_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"quyun/app/service/testx"
|
||||
"quyun/database"
|
||||
"quyun/database/schemas/public/table"
|
||||
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"go.ipao.vip/atom/contracts"
|
||||
|
||||
// . "github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"go.uber.org/dig"
|
||||
)
|
||||
|
||||
type OrdersInjectParams struct {
|
||||
dig.In
|
||||
Initials []contracts.Initial `group:"initials"`
|
||||
}
|
||||
|
||||
type OrdersTestSuite struct {
|
||||
suite.Suite
|
||||
|
||||
OrdersInjectParams
|
||||
}
|
||||
|
||||
func Test_Orders(t *testing.T) {
|
||||
providers := testx.Default().With(Provide)
|
||||
testx.Serve(providers, t, func(params OrdersInjectParams) {
|
||||
suite.Run(t, &OrdersTestSuite{
|
||||
OrdersInjectParams: params,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *OrdersTestSuite) Test_Create() {
|
||||
Convey("Test_Create", s.T(), func() {
|
||||
database.Truncate(context.Background(), db, table.Orders.TableName())
|
||||
|
||||
order, err := Orders.Create(context.Background(), 1, 1)
|
||||
So(err, ShouldBeNil)
|
||||
s.T().Logf("order: %v", order)
|
||||
})
|
||||
}
|
||||
@@ -23,12 +23,14 @@ func Provide(opts ...opt.Option) error {
|
||||
if err := container.Container.Provide(func(
|
||||
db *sql.DB,
|
||||
medias *mediasModel,
|
||||
orders *ordersModel,
|
||||
posts *postsModel,
|
||||
users *usersModel,
|
||||
) (contracts.Initial, error) {
|
||||
obj := &models{
|
||||
db: db,
|
||||
medias: medias,
|
||||
orders: orders,
|
||||
posts: posts,
|
||||
users: users,
|
||||
}
|
||||
@@ -40,6 +42,16 @@ func Provide(opts ...opt.Option) error {
|
||||
}, atom.GroupInitial); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := container.Container.Provide(func() (*ordersModel, error) {
|
||||
obj := &ordersModel{}
|
||||
if err := obj.Prepare(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return obj, nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := container.Container.Provide(func() (*postsModel, error) {
|
||||
obj := &postsModel{}
|
||||
if err := obj.Prepare(); err != nil {
|
||||
|
||||
259
backend/database/fields/orders.gen.go
Normal file
259
backend/database/fields/orders.gen.go
Normal file
@@ -0,0 +1,259 @@
|
||||
// Code generated by go-enum DO NOT EDIT.
|
||||
// Version: -
|
||||
// Revision: -
|
||||
// Build Date: -
|
||||
// Built By: -
|
||||
|
||||
package fields
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// OrderStatusPending is a OrderStatus of type Pending.
|
||||
OrderStatusPending OrderStatus = iota
|
||||
// OrderStatusPaid is a OrderStatus of type Paid.
|
||||
OrderStatusPaid
|
||||
// OrderStatusRefunding is a OrderStatus of type Refunding.
|
||||
OrderStatusRefunding
|
||||
// OrderStatusRefunded is a OrderStatus of type Refunded.
|
||||
OrderStatusRefunded
|
||||
// OrderStatusCancelled is a OrderStatus of type Cancelled.
|
||||
OrderStatusCancelled
|
||||
// OrderStatusCompleted is a OrderStatus of type Completed.
|
||||
OrderStatusCompleted
|
||||
)
|
||||
|
||||
var ErrInvalidOrderStatus = fmt.Errorf("not a valid OrderStatus, try [%s]", strings.Join(_OrderStatusNames, ", "))
|
||||
|
||||
const _OrderStatusName = "pendingpaidrefundingrefundedcancelledcompleted"
|
||||
|
||||
var _OrderStatusNames = []string{
|
||||
_OrderStatusName[0:7],
|
||||
_OrderStatusName[7:11],
|
||||
_OrderStatusName[11:20],
|
||||
_OrderStatusName[20:28],
|
||||
_OrderStatusName[28:37],
|
||||
_OrderStatusName[37:46],
|
||||
}
|
||||
|
||||
// OrderStatusNames returns a list of possible string values of OrderStatus.
|
||||
func OrderStatusNames() []string {
|
||||
tmp := make([]string, len(_OrderStatusNames))
|
||||
copy(tmp, _OrderStatusNames)
|
||||
return tmp
|
||||
}
|
||||
|
||||
// OrderStatusValues returns a list of the values for OrderStatus
|
||||
func OrderStatusValues() []OrderStatus {
|
||||
return []OrderStatus{
|
||||
OrderStatusPending,
|
||||
OrderStatusPaid,
|
||||
OrderStatusRefunding,
|
||||
OrderStatusRefunded,
|
||||
OrderStatusCancelled,
|
||||
OrderStatusCompleted,
|
||||
}
|
||||
}
|
||||
|
||||
var _OrderStatusMap = map[OrderStatus]string{
|
||||
OrderStatusPending: _OrderStatusName[0:7],
|
||||
OrderStatusPaid: _OrderStatusName[7:11],
|
||||
OrderStatusRefunding: _OrderStatusName[11:20],
|
||||
OrderStatusRefunded: _OrderStatusName[20:28],
|
||||
OrderStatusCancelled: _OrderStatusName[28:37],
|
||||
OrderStatusCompleted: _OrderStatusName[37:46],
|
||||
}
|
||||
|
||||
// String implements the Stringer interface.
|
||||
func (x OrderStatus) String() string {
|
||||
if str, ok := _OrderStatusMap[x]; ok {
|
||||
return str
|
||||
}
|
||||
return fmt.Sprintf("OrderStatus(%d)", x)
|
||||
}
|
||||
|
||||
// IsValid provides a quick way to determine if the typed value is
|
||||
// part of the allowed enumerated values
|
||||
func (x OrderStatus) IsValid() bool {
|
||||
_, ok := _OrderStatusMap[x]
|
||||
return ok
|
||||
}
|
||||
|
||||
var _OrderStatusValue = map[string]OrderStatus{
|
||||
_OrderStatusName[0:7]: OrderStatusPending,
|
||||
_OrderStatusName[7:11]: OrderStatusPaid,
|
||||
_OrderStatusName[11:20]: OrderStatusRefunding,
|
||||
_OrderStatusName[20:28]: OrderStatusRefunded,
|
||||
_OrderStatusName[28:37]: OrderStatusCancelled,
|
||||
_OrderStatusName[37:46]: OrderStatusCompleted,
|
||||
}
|
||||
|
||||
// ParseOrderStatus attempts to convert a string to a OrderStatus.
|
||||
func ParseOrderStatus(name string) (OrderStatus, error) {
|
||||
if x, ok := _OrderStatusValue[name]; ok {
|
||||
return x, nil
|
||||
}
|
||||
return OrderStatus(0), fmt.Errorf("%s is %w", name, ErrInvalidOrderStatus)
|
||||
}
|
||||
|
||||
var errOrderStatusNilPtr = errors.New("value pointer is nil") // one per type for package clashes
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (x *OrderStatus) Scan(value interface{}) (err error) {
|
||||
if value == nil {
|
||||
*x = OrderStatus(0)
|
||||
return
|
||||
}
|
||||
|
||||
// A wider range of scannable types.
|
||||
// driver.Value values at the top of the list for expediency
|
||||
switch v := value.(type) {
|
||||
case int64:
|
||||
*x = OrderStatus(v)
|
||||
case string:
|
||||
*x, err = ParseOrderStatus(v)
|
||||
if err != nil {
|
||||
// try parsing the integer value as a string
|
||||
if val, verr := strconv.Atoi(v); verr == nil {
|
||||
*x, err = OrderStatus(val), nil
|
||||
}
|
||||
}
|
||||
case []byte:
|
||||
*x, err = ParseOrderStatus(string(v))
|
||||
if err != nil {
|
||||
// try parsing the integer value as a string
|
||||
if val, verr := strconv.Atoi(string(v)); verr == nil {
|
||||
*x, err = OrderStatus(val), nil
|
||||
}
|
||||
}
|
||||
case OrderStatus:
|
||||
*x = v
|
||||
case int:
|
||||
*x = OrderStatus(v)
|
||||
case *OrderStatus:
|
||||
if v == nil {
|
||||
return errOrderStatusNilPtr
|
||||
}
|
||||
*x = *v
|
||||
case uint:
|
||||
*x = OrderStatus(v)
|
||||
case uint64:
|
||||
*x = OrderStatus(v)
|
||||
case *int:
|
||||
if v == nil {
|
||||
return errOrderStatusNilPtr
|
||||
}
|
||||
*x = OrderStatus(*v)
|
||||
case *int64:
|
||||
if v == nil {
|
||||
return errOrderStatusNilPtr
|
||||
}
|
||||
*x = OrderStatus(*v)
|
||||
case float64: // json marshals everything as a float64 if it's a number
|
||||
*x = OrderStatus(v)
|
||||
case *float64: // json marshals everything as a float64 if it's a number
|
||||
if v == nil {
|
||||
return errOrderStatusNilPtr
|
||||
}
|
||||
*x = OrderStatus(*v)
|
||||
case *uint:
|
||||
if v == nil {
|
||||
return errOrderStatusNilPtr
|
||||
}
|
||||
*x = OrderStatus(*v)
|
||||
case *uint64:
|
||||
if v == nil {
|
||||
return errOrderStatusNilPtr
|
||||
}
|
||||
*x = OrderStatus(*v)
|
||||
case *string:
|
||||
if v == nil {
|
||||
return errOrderStatusNilPtr
|
||||
}
|
||||
*x, err = ParseOrderStatus(*v)
|
||||
if err != nil {
|
||||
// try parsing the integer value as a string
|
||||
if val, verr := strconv.Atoi(*v); verr == nil {
|
||||
*x, err = OrderStatus(val), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (x OrderStatus) Value() (driver.Value, error) {
|
||||
return int64(x), nil
|
||||
}
|
||||
|
||||
// Set implements the Golang flag.Value interface func.
|
||||
func (x *OrderStatus) Set(val string) error {
|
||||
v, err := ParseOrderStatus(val)
|
||||
*x = v
|
||||
return err
|
||||
}
|
||||
|
||||
// Get implements the Golang flag.Getter interface func.
|
||||
func (x *OrderStatus) Get() interface{} {
|
||||
return *x
|
||||
}
|
||||
|
||||
// Type implements the github.com/spf13/pFlag Value interface.
|
||||
func (x *OrderStatus) Type() string {
|
||||
return "OrderStatus"
|
||||
}
|
||||
|
||||
type NullOrderStatus struct {
|
||||
OrderStatus OrderStatus
|
||||
Valid bool
|
||||
}
|
||||
|
||||
func NewNullOrderStatus(val interface{}) (x NullOrderStatus) {
|
||||
x.Scan(val) // yes, we ignore this error, it will just be an invalid value.
|
||||
return
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (x *NullOrderStatus) Scan(value interface{}) (err error) {
|
||||
if value == nil {
|
||||
x.OrderStatus, x.Valid = OrderStatus(0), false
|
||||
return
|
||||
}
|
||||
|
||||
err = x.OrderStatus.Scan(value)
|
||||
x.Valid = (err == nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (x NullOrderStatus) Value() (driver.Value, error) {
|
||||
if !x.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
// driver.Value accepts int64 for int values.
|
||||
return int64(x.OrderStatus), nil
|
||||
}
|
||||
|
||||
type NullOrderStatusStr struct {
|
||||
NullOrderStatus
|
||||
}
|
||||
|
||||
func NewNullOrderStatusStr(val interface{}) (x NullOrderStatusStr) {
|
||||
x.Scan(val) // yes, we ignore this error, it will just be an invalid value.
|
||||
return
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (x NullOrderStatusStr) Value() (driver.Value, error) {
|
||||
if !x.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return x.OrderStatus.String(), nil
|
||||
}
|
||||
7
backend/database/fields/orders.go
Normal file
7
backend/database/fields/orders.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package fields
|
||||
|
||||
// swagger:enum OrderStatus
|
||||
// ENUM( pending, paid, refunding, refunded, cancelled, completed)
|
||||
type OrderStatus int16
|
||||
|
||||
type OrderMeta struct{}
|
||||
26
backend/database/migrations/20250410130530_create_orders.sql
Normal file
26
backend/database/migrations/20250410130530_create_orders.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE orders(
|
||||
id SERIAL8 PRIMARY KEY,
|
||||
created_at timestamp NOT NULL DEFAULT now(),
|
||||
updated_at timestamp NOT NULL DEFAULT now(),
|
||||
order_no varchar(64) NOT NULL,
|
||||
sub_order_no varchar(64) NOT NULL DEFAULT '',
|
||||
transaction_id varchar(64) NOT NULL DEFAULT '',
|
||||
refund_transaction_id varchar(64) NOT NULL DEFAULT '',
|
||||
price int8 NOT NULL DEFAULT 0,
|
||||
discount int2 NOT NULL DEFAULT 100,
|
||||
currency varchar(10) NOT NULL DEFAULT 'CNY',
|
||||
payment_method varchar(50) NOT NULL DEFAULT 'wechatpay',
|
||||
post_id int8 NOT NULL,
|
||||
user_id int8 NOT NULL,
|
||||
status int2 NOT NULL,
|
||||
meta jsonb NOT NULL DEFAULT '{}' ::jsonb
|
||||
);
|
||||
|
||||
-- +goose StatementEnd
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE orders;
|
||||
|
||||
-- +goose StatementEnd
|
||||
31
backend/database/schemas/public/model/orders.go
Normal file
31
backend/database/schemas/public/model/orders.go
Normal file
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"quyun/database/fields"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Orders struct {
|
||||
ID int64 `sql:"primary_key" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
OrderNo string `json:"order_no"`
|
||||
SubOrderNo string `json:"sub_order_no"`
|
||||
TransactionID string `json:"transaction_id"`
|
||||
RefundTransactionID string `json:"refund_transaction_id"`
|
||||
Price int64 `json:"price"`
|
||||
Discount int16 `json:"discount"`
|
||||
Currency string `json:"currency"`
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
PostID int64 `json:"post_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Status fields.OrderStatus `json:"status"`
|
||||
Meta fields.Json[fields.OrderMeta] `json:"meta"`
|
||||
}
|
||||
117
backend/database/schemas/public/table/orders.go
Normal file
117
backend/database/schemas/public/table/orders.go
Normal file
@@ -0,0 +1,117 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var Orders = newOrdersTable("public", "orders", "")
|
||||
|
||||
type ordersTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
ID postgres.ColumnInteger
|
||||
CreatedAt postgres.ColumnTimestamp
|
||||
UpdatedAt postgres.ColumnTimestamp
|
||||
OrderNo postgres.ColumnString
|
||||
SubOrderNo postgres.ColumnString
|
||||
TransactionID postgres.ColumnString
|
||||
RefundTransactionID postgres.ColumnString
|
||||
Price postgres.ColumnInteger
|
||||
Discount postgres.ColumnInteger
|
||||
Currency postgres.ColumnString
|
||||
PaymentMethod postgres.ColumnString
|
||||
PostID postgres.ColumnInteger
|
||||
UserID postgres.ColumnInteger
|
||||
Status postgres.ColumnInteger
|
||||
Meta postgres.ColumnString
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type OrdersTable struct {
|
||||
ordersTable
|
||||
|
||||
EXCLUDED ordersTable
|
||||
}
|
||||
|
||||
// AS creates new OrdersTable with assigned alias
|
||||
func (a OrdersTable) AS(alias string) *OrdersTable {
|
||||
return newOrdersTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new OrdersTable with assigned schema name
|
||||
func (a OrdersTable) FromSchema(schemaName string) *OrdersTable {
|
||||
return newOrdersTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new OrdersTable with assigned table prefix
|
||||
func (a OrdersTable) WithPrefix(prefix string) *OrdersTable {
|
||||
return newOrdersTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new OrdersTable with assigned table suffix
|
||||
func (a OrdersTable) WithSuffix(suffix string) *OrdersTable {
|
||||
return newOrdersTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newOrdersTable(schemaName, tableName, alias string) *OrdersTable {
|
||||
return &OrdersTable{
|
||||
ordersTable: newOrdersTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newOrdersTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newOrdersTableImpl(schemaName, tableName, alias string) ordersTable {
|
||||
var (
|
||||
IDColumn = postgres.IntegerColumn("id")
|
||||
CreatedAtColumn = postgres.TimestampColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampColumn("updated_at")
|
||||
OrderNoColumn = postgres.StringColumn("order_no")
|
||||
SubOrderNoColumn = postgres.StringColumn("sub_order_no")
|
||||
TransactionIDColumn = postgres.StringColumn("transaction_id")
|
||||
RefundTransactionIDColumn = postgres.StringColumn("refund_transaction_id")
|
||||
PriceColumn = postgres.IntegerColumn("price")
|
||||
DiscountColumn = postgres.IntegerColumn("discount")
|
||||
CurrencyColumn = postgres.StringColumn("currency")
|
||||
PaymentMethodColumn = postgres.StringColumn("payment_method")
|
||||
PostIDColumn = postgres.IntegerColumn("post_id")
|
||||
UserIDColumn = postgres.IntegerColumn("user_id")
|
||||
StatusColumn = postgres.IntegerColumn("status")
|
||||
MetaColumn = postgres.StringColumn("meta")
|
||||
allColumns = postgres.ColumnList{IDColumn, CreatedAtColumn, UpdatedAtColumn, OrderNoColumn, SubOrderNoColumn, TransactionIDColumn, RefundTransactionIDColumn, PriceColumn, DiscountColumn, CurrencyColumn, PaymentMethodColumn, PostIDColumn, UserIDColumn, StatusColumn, MetaColumn}
|
||||
mutableColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn, OrderNoColumn, SubOrderNoColumn, TransactionIDColumn, RefundTransactionIDColumn, PriceColumn, DiscountColumn, CurrencyColumn, PaymentMethodColumn, PostIDColumn, UserIDColumn, StatusColumn, MetaColumn}
|
||||
)
|
||||
|
||||
return ordersTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
ID: IDColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
OrderNo: OrderNoColumn,
|
||||
SubOrderNo: SubOrderNoColumn,
|
||||
TransactionID: TransactionIDColumn,
|
||||
RefundTransactionID: RefundTransactionIDColumn,
|
||||
Price: PriceColumn,
|
||||
Discount: DiscountColumn,
|
||||
Currency: CurrencyColumn,
|
||||
PaymentMethod: PaymentMethodColumn,
|
||||
PostID: PostIDColumn,
|
||||
UserID: UserIDColumn,
|
||||
Status: StatusColumn,
|
||||
Meta: MetaColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ package table
|
||||
func UseSchema(schema string) {
|
||||
Medias = Medias.FromSchema(schema)
|
||||
Migrations = Migrations.FromSchema(schema)
|
||||
Orders = Orders.FromSchema(schema)
|
||||
Posts = Posts.FromSchema(schema)
|
||||
UserPosts = UserPosts.FromSchema(schema)
|
||||
Users = Users.FromSchema(schema)
|
||||
|
||||
@@ -16,7 +16,11 @@ types:
|
||||
status: PostStatus
|
||||
assets: Json[[]MediaAsset]
|
||||
tags: Json[[]string]
|
||||
meta: PostMeta
|
||||
meta: Json[PostMeta]
|
||||
|
||||
users:
|
||||
status: UserStatus
|
||||
|
||||
orders:
|
||||
status: OrderStatus
|
||||
meta: Json[OrderMeta]
|
||||
|
||||
@@ -50,4 +50,8 @@ DELETE {{host}}/v1/admin/posts/103 HTTP/1.1
|
||||
|
||||
### get users
|
||||
GET {{host}}/v1/admin/users HTTP/1.1
|
||||
Content-Type: application/json
|
||||
|
||||
### get orders
|
||||
GET {{host}}/v1/admin/orders HTTP/1.1
|
||||
Content-Type: application/json
|
||||
@@ -27,6 +27,11 @@ const navItems = ref([
|
||||
icon: 'pi pi-users',
|
||||
command: () => router.push('/users')
|
||||
},
|
||||
{
|
||||
label: 'Orders',
|
||||
icon: 'pi pi-shopping-cart',
|
||||
command: () => router.push('/orders')
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: 'pi pi-cog',
|
||||
|
||||
16
frontend/admin/src/api/orderService.js
Normal file
16
frontend/admin/src/api/orderService.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import httpClient from './httpClient';
|
||||
|
||||
export const orderService = {
|
||||
getOrders({ page = 1, limit = 10, keyword = '' } = {}) {
|
||||
return httpClient.get('/admin/orders', {
|
||||
params: {
|
||||
page,
|
||||
limit,
|
||||
keyword: keyword.trim()
|
||||
}
|
||||
});
|
||||
},
|
||||
deleteOrder(id) {
|
||||
return httpClient.delete(`/admin/orders/${id}`);
|
||||
}
|
||||
}
|
||||
24
frontend/admin/src/api/order_list.json
Normal file
24
frontend/admin/src/api/order_list.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"total": 1,
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"created_at": "2025-04-10T21:30:27.585874Z",
|
||||
"updated_at": "2025-04-10T21:30:27.585877Z",
|
||||
"order_no": "20250410213027",
|
||||
"sub_order_no": "20250410213027",
|
||||
"transaction_id": "",
|
||||
"refund_transaction_id": "",
|
||||
"price": 325,
|
||||
"discount": 58,
|
||||
"currency": "",
|
||||
"payment_method": "",
|
||||
"post_id": 1,
|
||||
"user_id": 1,
|
||||
"status": 0,
|
||||
"meta": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
207
frontend/admin/src/pages/OrderPage.vue
Normal file
207
frontend/admin/src/pages/OrderPage.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<script setup>
|
||||
import { orderService } from '@/api/orderService';
|
||||
import dayjs from 'dayjs';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import Badge from 'primevue/badge';
|
||||
import Button from 'primevue/button';
|
||||
import Column from 'primevue/column';
|
||||
import ConfirmDialog from 'primevue/confirmdialog';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import ProgressSpinner from 'primevue/progressspinner';
|
||||
import Toast from 'primevue/toast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const globalFilterValue = ref('');
|
||||
const loading = ref(false);
|
||||
const searchTimeout = ref(null);
|
||||
const filters = ref({
|
||||
global: { value: null, matchMode: 'contains' },
|
||||
status: { value: null, matchMode: 'equals' }
|
||||
});
|
||||
|
||||
const orders = ref({
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10
|
||||
});
|
||||
|
||||
const first = ref(0);
|
||||
const rows = ref(10);
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const orderStatusMap = {
|
||||
0: { label: '待支付', severity: 'warning' },
|
||||
1: { label: '已支付', severity: 'success' },
|
||||
2: { label: '已退款', severity: 'info' },
|
||||
3: { label: '已取消', severity: 'danger' }
|
||||
};
|
||||
|
||||
const formatPrice = (price) => {
|
||||
return (price / 100).toFixed(2);
|
||||
};
|
||||
|
||||
const getDiscountAmount = (price, discount) => {
|
||||
return (price * discount / 100);
|
||||
};
|
||||
|
||||
const getFinalPrice = (price, discount) => {
|
||||
return price - getDiscountAmount(price, discount);
|
||||
};
|
||||
|
||||
const fetchOrders = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const currentPage = (first.value / rows.value) + 1;
|
||||
const response = await orderService.getOrders({
|
||||
page: currentPage,
|
||||
limit: rows.value,
|
||||
keyword: globalFilterValue.value
|
||||
});
|
||||
orders.value = response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch orders:', error);
|
||||
toast.add({ severity: 'error', summary: '错误', detail: '加载订单数据失败', life: 3000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onPage = (event) => {
|
||||
first.value = event.first;
|
||||
rows.value = event.rows;
|
||||
fetchOrders();
|
||||
};
|
||||
|
||||
const onSearch = (event) => {
|
||||
if (searchTimeout.value) {
|
||||
clearTimeout(searchTimeout.value);
|
||||
}
|
||||
|
||||
searchTimeout.value = setTimeout(() => {
|
||||
first.value = 0;
|
||||
fetchOrders();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const formatDate = (date) => {
|
||||
return dayjs.tz(date, 'Asia/Shanghai').format('YYYY-MM-DD HH:mm:ss');
|
||||
};
|
||||
|
||||
const handleDelete = (order) => {
|
||||
confirm.require({
|
||||
message: `确定要删除订单 "${order.id}" 吗?`,
|
||||
header: '确认删除',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
await orderService.deleteOrder(order.id);
|
||||
toast.add({ severity: 'success', summary: '成功', detail: '订单已删除', life: 3000 });
|
||||
fetchOrders();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete order:', error);
|
||||
toast.add({ severity: 'error', summary: '错误', detail: '删除订单失败', life: 3000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchOrders();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
<ConfirmDialog />
|
||||
|
||||
<div class="w-full">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-semibold text-gray-800">订单列表</h1>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="pb-10 flex">
|
||||
<InputText v-model="globalFilterValue" placeholder="搜索订单..." class="flex-1" @input="onSearch" />
|
||||
</div>
|
||||
|
||||
<DataTable v-model:filters="filters" :value="orders.items" :paginator="true" :rows="rows"
|
||||
:totalRecords="orders.total" :loading="loading" :lazy="true" :first="first" @page="onPage"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
:rowsPerPageOptions="[10, 25, 50]"
|
||||
currentPageReportTemplate="显示第 {first} 到 {last} 条,共 {totalRecords} 条结果" dataKey="id"
|
||||
:globalFilterFields="['order_no', 'user_id']" stripedRows removableSort class="p-datatable-sm"
|
||||
responsiveLayout="scroll">
|
||||
|
||||
<template #empty>
|
||||
<div class="text-center p-4">未找到订单。</div>
|
||||
</template>
|
||||
|
||||
<template #loading>
|
||||
<div class="flex flex-col items-center justify-center p-4">
|
||||
<ProgressSpinner style="width:50px;height:50px" />
|
||||
<span class="mt-2">加载订单数据...</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Column field="id" header="ID" sortable></Column>
|
||||
<Column field="order_no" header="订单号" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-gray-700">系统订单号: {{ data.order_no }}</span>
|
||||
<span class="text-gray-500">商户订单号: {{ data.transaction_id || '-' }}</span>
|
||||
<span class="text-orange-500">退款订单号: {{ data.refund_transaction_id || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="状态" sortable>
|
||||
<template #body="{ data }">
|
||||
<Badge :value="orderStatusMap[data.status]?.label"
|
||||
:severity="orderStatusMap[data.status]?.severity" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="user_id" header="用户ID" sortable></Column>
|
||||
<Column field="post_id" header="文章ID" sortable></Column>
|
||||
<Column field="price" header="价格信息" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-gray-500">原价: ¥{{ formatPrice(data.price) }}</span>
|
||||
<span class="text-orange-500">优惠: -¥{{ formatPrice(getDiscountAmount(data.price,
|
||||
data.discount)) }}</span>
|
||||
<span class="font-bold">实付: ¥{{ formatPrice(getFinalPrice(data.price, data.discount))
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="updated_at" header="时间信息" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-gray-500">更新: {{ formatDate(data.updated_at) }}</span>
|
||||
<span class="text-gray-400">创建: {{ formatDate(data.created_at) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="操作" :exportable="false" style="min-width:8rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex justify-center space-x-2">
|
||||
<Button icon="pi pi-trash" rounded text severity="danger" @click="handleDelete(data)"
|
||||
aria-label="删除" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -39,6 +39,11 @@ const routes = [
|
||||
path: '/users',
|
||||
name: 'Users',
|
||||
component: () => import('./pages/UserPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/orders',
|
||||
name: 'Orders',
|
||||
component: () => import('./pages/OrderPage.vue'),
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user