feat: 实现订单支付功能模块
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m36s

- 新增订单管理、支付回调、购买验证等核心服务
- 实现订单、订单项目的数据存储层和 API 接口
- 添加订单数据库迁移和 DTO 定义
- 更新 API 文档和路由配置
- 同步 3 个新规范到主规范库(订单管理、订单支付、套餐购买验证)
- 完成 OpenSpec 变更归档

Ultraworked with Sisyphus
This commit is contained in:
2026-01-28 22:12:15 +08:00
parent a945a4f554
commit dfcf16f548
39 changed files with 3795 additions and 126 deletions

View File

@@ -0,0 +1,47 @@
package postgres
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type OrderItemStore struct {
db *gorm.DB
redis *redis.Client
}
func NewOrderItemStore(db *gorm.DB, redis *redis.Client) *OrderItemStore {
return &OrderItemStore{
db: db,
redis: redis,
}
}
func (s *OrderItemStore) BatchCreate(ctx context.Context, items []*model.OrderItem) error {
if len(items) == 0 {
return nil
}
return s.db.WithContext(ctx).Create(&items).Error
}
func (s *OrderItemStore) ListByOrderID(ctx context.Context, orderID uint) ([]*model.OrderItem, error) {
var items []*model.OrderItem
if err := s.db.WithContext(ctx).Where("order_id = ?", orderID).Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (s *OrderItemStore) ListByOrderIDs(ctx context.Context, orderIDs []uint) ([]*model.OrderItem, error) {
if len(orderIDs) == 0 {
return nil, nil
}
var items []*model.OrderItem
if err := s.db.WithContext(ctx).Where("order_id IN ?", orderIDs).Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}

View File

@@ -0,0 +1,142 @@
package postgres
import (
"context"
"testing"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/tests/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOrderItemStore_BatchCreate(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
orderStore := NewOrderStore(tx, rdb)
itemStore := NewOrderItemStore(tx, rdb)
ctx := context.Background()
order := &model.Order{
OrderNo: orderStore.GenerateOrderNo(),
OrderType: model.OrderTypeSingleCard,
BuyerType: model.BuyerTypePersonal,
BuyerID: 100,
TotalAmount: 15000,
PaymentStatus: model.PaymentStatusPending,
}
require.NoError(t, orderStore.Create(ctx, order, nil))
items := []*model.OrderItem{
{OrderID: order.ID, PackageID: 1, PackageName: "套餐A", Quantity: 1, UnitPrice: 5000, Amount: 5000},
{OrderID: order.ID, PackageID: 2, PackageName: "套餐B", Quantity: 2, UnitPrice: 5000, Amount: 10000},
}
err := itemStore.BatchCreate(ctx, items)
require.NoError(t, err)
for _, item := range items {
assert.NotZero(t, item.ID)
assert.Equal(t, order.ID, item.OrderID)
}
}
func TestOrderItemStore_BatchCreate_Empty(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
itemStore := NewOrderItemStore(tx, rdb)
ctx := context.Background()
err := itemStore.BatchCreate(ctx, nil)
require.NoError(t, err)
err = itemStore.BatchCreate(ctx, []*model.OrderItem{})
require.NoError(t, err)
}
func TestOrderItemStore_ListByOrderID(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
orderStore := NewOrderStore(tx, rdb)
itemStore := NewOrderItemStore(tx, rdb)
ctx := context.Background()
order := &model.Order{
OrderNo: orderStore.GenerateOrderNo(),
OrderType: model.OrderTypeDevice,
BuyerType: model.BuyerTypeAgent,
BuyerID: 200,
TotalAmount: 20000,
PaymentStatus: model.PaymentStatusPending,
}
items := []*model.OrderItem{
{PackageID: 10, PackageName: "设备套餐1", Quantity: 1, UnitPrice: 10000, Amount: 10000},
{PackageID: 11, PackageName: "设备套餐2", Quantity: 1, UnitPrice: 10000, Amount: 10000},
}
require.NoError(t, orderStore.Create(ctx, order, items))
result, err := itemStore.ListByOrderID(ctx, order.ID)
require.NoError(t, err)
assert.Len(t, result, 2)
for _, item := range result {
assert.Equal(t, order.ID, item.OrderID)
}
}
func TestOrderItemStore_ListByOrderIDs(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
orderStore := NewOrderStore(tx, rdb)
itemStore := NewOrderItemStore(tx, rdb)
ctx := context.Background()
order1 := &model.Order{
OrderNo: orderStore.GenerateOrderNo(),
OrderType: model.OrderTypeSingleCard,
BuyerType: model.BuyerTypePersonal,
BuyerID: 300,
TotalAmount: 5000,
PaymentStatus: model.PaymentStatusPending,
}
items1 := []*model.OrderItem{
{PackageID: 20, PackageName: "套餐X", Quantity: 1, UnitPrice: 5000, Amount: 5000},
}
require.NoError(t, orderStore.Create(ctx, order1, items1))
order2 := &model.Order{
OrderNo: orderStore.GenerateOrderNo(),
OrderType: model.OrderTypeSingleCard,
BuyerType: model.BuyerTypePersonal,
BuyerID: 300,
TotalAmount: 8000,
PaymentStatus: model.PaymentStatusPending,
}
items2 := []*model.OrderItem{
{PackageID: 21, PackageName: "套餐Y", Quantity: 1, UnitPrice: 3000, Amount: 3000},
{PackageID: 22, PackageName: "套餐Z", Quantity: 1, UnitPrice: 5000, Amount: 5000},
}
require.NoError(t, orderStore.Create(ctx, order2, items2))
t.Run("查询多个订单的明细", func(t *testing.T) {
result, err := itemStore.ListByOrderIDs(ctx, []uint{order1.ID, order2.ID})
require.NoError(t, err)
assert.Len(t, result, 3)
})
t.Run("空订单ID列表", func(t *testing.T) {
result, err := itemStore.ListByOrderIDs(ctx, []uint{})
require.NoError(t, err)
assert.Nil(t, result)
})
t.Run("不存在的订单ID", func(t *testing.T) {
result, err := itemStore.ListByOrderIDs(ctx, []uint{99999})
require.NoError(t, err)
assert.Len(t, result, 0)
})
}

View File

@@ -0,0 +1,148 @@
package postgres
import (
"context"
"fmt"
"math/rand"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type OrderStore struct {
db *gorm.DB
redis *redis.Client
}
func NewOrderStore(db *gorm.DB, redis *redis.Client) *OrderStore {
return &OrderStore{
db: db,
redis: redis,
}
}
func (s *OrderStore) Create(ctx context.Context, order *model.Order, items []*model.OrderItem) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(order).Error; err != nil {
return err
}
for _, item := range items {
item.OrderID = order.ID
if err := tx.Create(item).Error; err != nil {
return err
}
}
return nil
})
}
func (s *OrderStore) GetByID(ctx context.Context, id uint) (*model.Order, error) {
var order model.Order
if err := s.db.WithContext(ctx).First(&order, id).Error; err != nil {
return nil, err
}
return &order, nil
}
func (s *OrderStore) GetByIDWithItems(ctx context.Context, id uint) (*model.Order, []*model.OrderItem, error) {
var order model.Order
if err := s.db.WithContext(ctx).First(&order, id).Error; err != nil {
return nil, nil, err
}
var items []*model.OrderItem
if err := s.db.WithContext(ctx).Where("order_id = ?", id).Find(&items).Error; err != nil {
return nil, nil, err
}
return &order, items, nil
}
func (s *OrderStore) GetByOrderNo(ctx context.Context, orderNo string) (*model.Order, error) {
var order model.Order
if err := s.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&order).Error; err != nil {
return nil, err
}
return &order, nil
}
func (s *OrderStore) Update(ctx context.Context, order *model.Order) error {
return s.db.WithContext(ctx).Save(order).Error
}
func (s *OrderStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.Order, int64, error) {
var orders []*model.Order
var total int64
query := s.db.WithContext(ctx).Model(&model.Order{})
if v, ok := filters["payment_status"]; ok {
query = query.Where("payment_status = ?", v)
}
if v, ok := filters["order_type"]; ok {
query = query.Where("order_type = ?", v)
}
if v, ok := filters["order_no"]; ok {
query = query.Where("order_no = ?", v)
}
if v, ok := filters["buyer_type"]; ok {
query = query.Where("buyer_type = ?", v)
}
if v, ok := filters["buyer_id"]; ok {
query = query.Where("buyer_id = ?", v)
}
if v, ok := filters["iot_card_id"]; ok {
query = query.Where("iot_card_id = ?", v)
}
if v, ok := filters["device_id"]; ok {
query = query.Where("device_id = ?", v)
}
if v, ok := filters["start_time"]; ok {
query = query.Where("created_at >= ?", v)
}
if v, ok := filters["end_time"]; ok {
query = query.Where("created_at <= ?", v)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if opts == nil {
opts = store.DefaultQueryOptions()
}
offset := (opts.Page - 1) * opts.PageSize
if opts.OrderBy != "" {
query = query.Order(opts.OrderBy)
} else {
query = query.Order("id DESC")
}
if err := query.Offset(offset).Limit(opts.PageSize).Find(&orders).Error; err != nil {
return nil, 0, err
}
return orders, total, nil
}
func (s *OrderStore) UpdatePaymentStatus(ctx context.Context, id uint, status int, paidAt *time.Time) error {
updates := map[string]any{
"payment_status": status,
}
if paidAt != nil {
updates["paid_at"] = paidAt
}
return s.db.WithContext(ctx).Model(&model.Order{}).Where("id = ?", id).Updates(updates).Error
}
func (s *OrderStore) GenerateOrderNo() string {
now := time.Now()
randomNum := rand.Intn(1000000)
return fmt.Sprintf("ORD%s%06d", now.Format("20060102150405"), randomNum)
}

View File

@@ -0,0 +1,287 @@
package postgres
import (
"context"
"testing"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/tests/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOrderStore_Create(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewOrderStore(tx, rdb)
ctx := context.Background()
cardID := uint(1001)
order := &model.Order{
OrderNo: s.GenerateOrderNo(),
OrderType: model.OrderTypeSingleCard,
BuyerType: model.BuyerTypePersonal,
BuyerID: 100,
IotCardID: &cardID,
TotalAmount: 9900,
PaymentStatus: model.PaymentStatusPending,
}
items := []*model.OrderItem{
{
PackageID: 1,
PackageName: "测试套餐1",
Quantity: 1,
UnitPrice: 5000,
Amount: 5000,
},
{
PackageID: 2,
PackageName: "测试套餐2",
Quantity: 1,
UnitPrice: 4900,
Amount: 4900,
},
}
err := s.Create(ctx, order, items)
require.NoError(t, err)
assert.NotZero(t, order.ID)
for _, item := range items {
assert.NotZero(t, item.ID)
assert.Equal(t, order.ID, item.OrderID)
}
}
func TestOrderStore_GetByID(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewOrderStore(tx, rdb)
ctx := context.Background()
order := &model.Order{
OrderNo: s.GenerateOrderNo(),
OrderType: model.OrderTypeSingleCard,
BuyerType: model.BuyerTypeAgent,
BuyerID: 200,
TotalAmount: 19900,
PaymentStatus: model.PaymentStatusPending,
}
require.NoError(t, s.Create(ctx, order, nil))
t.Run("查询存在的订单", func(t *testing.T) {
result, err := s.GetByID(ctx, order.ID)
require.NoError(t, err)
assert.Equal(t, order.OrderNo, result.OrderNo)
assert.Equal(t, order.BuyerType, result.BuyerType)
assert.Equal(t, order.TotalAmount, result.TotalAmount)
})
t.Run("查询不存在的订单", func(t *testing.T) {
_, err := s.GetByID(ctx, 99999)
require.Error(t, err)
})
}
func TestOrderStore_GetByIDWithItems(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewOrderStore(tx, rdb)
ctx := context.Background()
deviceID := uint(2001)
order := &model.Order{
OrderNo: s.GenerateOrderNo(),
OrderType: model.OrderTypeDevice,
BuyerType: model.BuyerTypePersonal,
BuyerID: 300,
DeviceID: &deviceID,
TotalAmount: 29900,
PaymentStatus: model.PaymentStatusPending,
}
items := []*model.OrderItem{
{PackageID: 10, PackageName: "设备套餐A", Quantity: 1, UnitPrice: 15000, Amount: 15000},
{PackageID: 11, PackageName: "设备套餐B", Quantity: 1, UnitPrice: 14900, Amount: 14900},
}
require.NoError(t, s.Create(ctx, order, items))
resultOrder, resultItems, err := s.GetByIDWithItems(ctx, order.ID)
require.NoError(t, err)
assert.Equal(t, order.OrderNo, resultOrder.OrderNo)
assert.Len(t, resultItems, 2)
}
func TestOrderStore_GetByOrderNo(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewOrderStore(tx, rdb)
ctx := context.Background()
orderNo := s.GenerateOrderNo()
order := &model.Order{
OrderNo: orderNo,
OrderType: model.OrderTypeSingleCard,
BuyerType: model.BuyerTypeAgent,
BuyerID: 400,
TotalAmount: 5000,
PaymentStatus: model.PaymentStatusPending,
}
require.NoError(t, s.Create(ctx, order, nil))
t.Run("查询存在的订单号", func(t *testing.T) {
result, err := s.GetByOrderNo(ctx, orderNo)
require.NoError(t, err)
assert.Equal(t, order.ID, result.ID)
})
t.Run("查询不存在的订单号", func(t *testing.T) {
_, err := s.GetByOrderNo(ctx, "NOT_EXISTS_ORDER_NO")
require.Error(t, err)
})
}
func TestOrderStore_Update(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewOrderStore(tx, rdb)
ctx := context.Background()
order := &model.Order{
OrderNo: s.GenerateOrderNo(),
OrderType: model.OrderTypeSingleCard,
BuyerType: model.BuyerTypePersonal,
BuyerID: 500,
TotalAmount: 10000,
PaymentStatus: model.PaymentStatusPending,
}
require.NoError(t, s.Create(ctx, order, nil))
order.PaymentMethod = model.PaymentMethodWallet
order.PaymentStatus = model.PaymentStatusPaid
now := time.Now()
order.PaidAt = &now
err := s.Update(ctx, order)
require.NoError(t, err)
updated, err := s.GetByID(ctx, order.ID)
require.NoError(t, err)
assert.Equal(t, model.PaymentMethodWallet, updated.PaymentMethod)
assert.Equal(t, model.PaymentStatusPaid, updated.PaymentStatus)
assert.NotNil(t, updated.PaidAt)
}
func TestOrderStore_UpdatePaymentStatus(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewOrderStore(tx, rdb)
ctx := context.Background()
order := &model.Order{
OrderNo: s.GenerateOrderNo(),
OrderType: model.OrderTypeSingleCard,
BuyerType: model.BuyerTypeAgent,
BuyerID: 600,
TotalAmount: 8000,
PaymentStatus: model.PaymentStatusPending,
}
require.NoError(t, s.Create(ctx, order, nil))
now := time.Now()
err := s.UpdatePaymentStatus(ctx, order.ID, model.PaymentStatusPaid, &now)
require.NoError(t, err)
updated, err := s.GetByID(ctx, order.ID)
require.NoError(t, err)
assert.Equal(t, model.PaymentStatusPaid, updated.PaymentStatus)
assert.NotNil(t, updated.PaidAt)
}
func TestOrderStore_List(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewOrderStore(tx, rdb)
ctx := context.Background()
orders := []*model.Order{
{OrderNo: s.GenerateOrderNo(), OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypePersonal, BuyerID: 700, TotalAmount: 1000, PaymentStatus: model.PaymentStatusPending},
{OrderNo: s.GenerateOrderNo(), OrderType: model.OrderTypeDevice, BuyerType: model.BuyerTypeAgent, BuyerID: 701, TotalAmount: 2000, PaymentStatus: model.PaymentStatusPaid},
{OrderNo: s.GenerateOrderNo(), OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypeAgent, BuyerID: 701, TotalAmount: 3000, PaymentStatus: model.PaymentStatusCancelled},
}
for _, o := range orders {
require.NoError(t, s.Create(ctx, o, nil))
}
t.Run("查询所有订单", func(t *testing.T) {
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, nil)
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(3))
assert.GreaterOrEqual(t, len(result), 3)
})
t.Run("按支付状态过滤", func(t *testing.T) {
filters := map[string]any{"payment_status": model.PaymentStatusPending}
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(1))
for _, o := range result {
assert.Equal(t, model.PaymentStatusPending, o.PaymentStatus)
}
})
t.Run("按订单类型过滤", func(t *testing.T) {
filters := map[string]any{"order_type": model.OrderTypeDevice}
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(1))
for _, o := range result {
assert.Equal(t, model.OrderTypeDevice, o.OrderType)
}
})
t.Run("按买家过滤", func(t *testing.T) {
filters := map[string]any{"buyer_type": model.BuyerTypeAgent, "buyer_id": uint(701)}
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(2))
for _, o := range result {
assert.Equal(t, model.BuyerTypeAgent, o.BuyerType)
assert.Equal(t, uint(701), o.BuyerID)
}
})
t.Run("分页查询", func(t *testing.T) {
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 2}, nil)
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(3))
assert.LessOrEqual(t, len(result), 2)
})
t.Run("默认分页选项", func(t *testing.T) {
result, _, err := s.List(ctx, nil, nil)
require.NoError(t, err)
assert.NotNil(t, result)
})
}
func TestOrderStore_GenerateOrderNo(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
s := NewOrderStore(tx, rdb)
orderNo1 := s.GenerateOrderNo()
orderNo2 := s.GenerateOrderNo()
assert.True(t, len(orderNo1) > 0)
assert.True(t, len(orderNo1) <= 30)
assert.Contains(t, orderNo1, "ORD")
assert.NotEqual(t, orderNo1, orderNo2)
}