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

@@ -3,6 +3,7 @@ package bootstrap
import (
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/handler/app"
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
"github.com/go-playground/validator/v10"
)
@@ -40,5 +41,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
ShopPackageAllocation: admin.NewShopPackageAllocationHandler(svc.ShopPackageAllocation),
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
AdminOrder: admin.NewOrderHandler(svc.Order),
H5Order: h5.NewOrderHandler(svc.Order),
PaymentCallback: callback.NewPaymentHandler(svc.Order),
}
}

View File

@@ -16,10 +16,12 @@ import (
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
iotCardImportSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card_import"
myCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/my_commission"
orderSvc "github.com/break/junhong_cmp_fiber/internal/service/order"
packageSvc "github.com/break/junhong_cmp_fiber/internal/service/package"
packageSeriesSvc "github.com/break/junhong_cmp_fiber/internal/service/package_series"
permissionSvc "github.com/break/junhong_cmp_fiber/internal/service/permission"
personalCustomerSvc "github.com/break/junhong_cmp_fiber/internal/service/personal_customer"
purchaseValidationSvc "github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
roleSvc "github.com/break/junhong_cmp_fiber/internal/service/role"
shopSvc "github.com/break/junhong_cmp_fiber/internal/service/shop"
shopAccountSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_account"
@@ -59,9 +61,13 @@ type services struct {
ShopPackageBatchAllocation *shopPackageBatchAllocationSvc.Service
ShopPackageBatchPricing *shopPackageBatchPricingSvc.Service
CommissionStats *commissionStatsSvc.Service
PurchaseValidation *purchaseValidationSvc.Service
Order *orderSvc.Service
}
func initServices(s *stores, deps *Dependencies) *services {
purchaseValidation := purchaseValidationSvc.New(deps.DB, s.IotCard, s.Device, s.Package, s.ShopSeriesAllocation)
return &services{
Account: accountSvc.New(s.Account, s.Role, s.AccountRole),
Role: roleSvc.New(s.Role, s.Permission, s.RolePermission),
@@ -91,5 +97,7 @@ func initServices(s *stores, deps *Dependencies) *services {
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopSeriesAllocationConfig, s.ShopSeriesCommissionTier, s.ShopSeriesCommissionStats, s.Shop),
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop),
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
PurchaseValidation: purchaseValidation,
Order: orderSvc.New(deps.DB, s.Order, s.OrderItem, s.Wallet, purchaseValidation, s.ShopSeriesAllocationConfig),
}
}

View File

@@ -35,6 +35,8 @@ type stores struct {
ShopPackageAllocation *postgres.ShopPackageAllocationStore
ShopPackageAllocationPriceHistory *postgres.ShopPackageAllocationPriceHistoryStore
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
Order *postgres.OrderStore
OrderItem *postgres.OrderItemStore
}
func initStores(deps *Dependencies) *stores {
@@ -69,5 +71,7 @@ func initStores(deps *Dependencies) *stores {
ShopPackageAllocation: postgres.NewShopPackageAllocationStore(deps.DB),
ShopPackageAllocationPriceHistory: postgres.NewShopPackageAllocationPriceHistoryStore(deps.DB),
ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
Order: postgres.NewOrderStore(deps.DB, deps.Redis),
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
}
}

View File

@@ -3,6 +3,7 @@ package bootstrap
import (
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/handler/app"
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/gofiber/fiber/v2"
@@ -38,6 +39,9 @@ type Handlers struct {
ShopPackageAllocation *admin.ShopPackageAllocationHandler
ShopPackageBatchAllocation *admin.ShopPackageBatchAllocationHandler
ShopPackageBatchPricing *admin.ShopPackageBatchPricingHandler
AdminOrder *admin.OrderHandler
H5Order *h5.OrderHandler
PaymentCallback *callback.PaymentHandler
}
// Middlewares 封装所有中间件

View File

@@ -0,0 +1,109 @@
package admin
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
orderService "github.com/break/junhong_cmp_fiber/internal/service/order"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
type OrderHandler struct {
service *orderService.Service
}
func NewOrderHandler(service *orderService.Service) *OrderHandler {
return &OrderHandler{service: service}
}
func (h *OrderHandler) Create(c *fiber.Ctx) error {
var req dto.CreateOrderRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
ctx := c.UserContext()
userType := middleware.GetUserTypeFromContext(ctx)
shopID := middleware.GetShopIDFromContext(ctx)
if userType != constants.UserTypeAgent {
return errors.New(errors.CodeForbidden, "只有代理账号可以创建订单")
}
order, err := h.service.Create(ctx, &req, model.BuyerTypeAgent, shopID)
if err != nil {
return err
}
return response.Success(c, order)
}
func (h *OrderHandler) Get(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
}
order, err := h.service.Get(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, order)
}
func (h *OrderHandler) List(c *fiber.Ctx) error {
var req dto.OrderListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
ctx := c.UserContext()
userType := middleware.GetUserTypeFromContext(ctx)
shopID := middleware.GetShopIDFromContext(ctx)
var buyerType string
var buyerID uint
if userType == constants.UserTypeAgent {
buyerType = model.BuyerTypeAgent
buyerID = shopID
} else {
buyerType = ""
buyerID = 0
}
orders, err := h.service.List(ctx, &req, buyerType, buyerID)
if err != nil {
return err
}
return response.Success(c, orders)
}
func (h *OrderHandler) Cancel(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
}
ctx := c.UserContext()
userType := middleware.GetUserTypeFromContext(ctx)
shopID := middleware.GetShopIDFromContext(ctx)
if userType != constants.UserTypeAgent {
return errors.New(errors.CodeForbidden, "只有代理账号可以取消订单")
}
if err := h.service.Cancel(ctx, uint(id), model.BuyerTypeAgent, shopID); err != nil {
return err
}
return response.Success(c, nil)
}

View File

@@ -0,0 +1,60 @@
package callback
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model"
orderService "github.com/break/junhong_cmp_fiber/internal/service/order"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
type PaymentHandler struct {
orderService *orderService.Service
}
func NewPaymentHandler(orderService *orderService.Service) *PaymentHandler {
return &PaymentHandler{orderService: orderService}
}
type WechatPayCallbackRequest struct {
OrderNo string `json:"order_no" xml:"out_trade_no"`
}
func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
var req WechatPayCallbackRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if req.OrderNo == "" {
return errors.New(errors.CodeInvalidParam, "订单号不能为空")
}
if err := h.orderService.HandlePaymentCallback(c.UserContext(), req.OrderNo, model.PaymentMethodWechat); err != nil {
return err
}
return response.Success(c, map[string]string{"return_code": "SUCCESS"})
}
type AlipayCallbackRequest struct {
OrderNo string `json:"out_trade_no" form:"out_trade_no"`
}
func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
var req AlipayCallbackRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if req.OrderNo == "" {
return errors.New(errors.CodeInvalidParam, "订单号不能为空")
}
if err := h.orderService.HandlePaymentCallback(c.UserContext(), req.OrderNo, model.PaymentMethodAlipay); err != nil {
return err
}
return c.SendString("success")
}

View File

@@ -0,0 +1,131 @@
package h5
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
orderService "github.com/break/junhong_cmp_fiber/internal/service/order"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
type OrderHandler struct {
service *orderService.Service
}
func NewOrderHandler(service *orderService.Service) *OrderHandler {
return &OrderHandler{service: service}
}
func (h *OrderHandler) Create(c *fiber.Ctx) error {
var req dto.CreateOrderRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
ctx := c.UserContext()
userType := middleware.GetUserTypeFromContext(ctx)
var buyerType string
var buyerID uint
switch userType {
case constants.UserTypeAgent:
buyerType = model.BuyerTypeAgent
buyerID = middleware.GetShopIDFromContext(ctx)
case constants.UserTypeEnterprise:
return errors.New(errors.CodeForbidden, "企业账号不支持在线购买")
case constants.UserTypePersonalCustomer:
buyerType = model.BuyerTypePersonal
buyerID = middleware.GetCustomerIDFromContext(ctx)
default:
return errors.New(errors.CodeForbidden, "不支持的用户类型")
}
order, err := h.service.Create(ctx, &req, buyerType, buyerID)
if err != nil {
return err
}
return response.Success(c, order)
}
func (h *OrderHandler) Get(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
}
order, err := h.service.Get(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, order)
}
func (h *OrderHandler) List(c *fiber.Ctx) error {
var req dto.OrderListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
ctx := c.UserContext()
userType := middleware.GetUserTypeFromContext(ctx)
var buyerType string
var buyerID uint
switch userType {
case constants.UserTypeAgent:
buyerType = model.BuyerTypeAgent
buyerID = middleware.GetShopIDFromContext(ctx)
case constants.UserTypePersonalCustomer:
buyerType = model.BuyerTypePersonal
buyerID = middleware.GetCustomerIDFromContext(ctx)
default:
return errors.New(errors.CodeForbidden, "不支持的用户类型")
}
orders, err := h.service.List(ctx, &req, buyerType, buyerID)
if err != nil {
return err
}
return response.Success(c, orders)
}
func (h *OrderHandler) WalletPay(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
}
ctx := c.UserContext()
userType := middleware.GetUserTypeFromContext(ctx)
var buyerType string
var buyerID uint
switch userType {
case constants.UserTypeAgent:
buyerType = model.BuyerTypeAgent
buyerID = middleware.GetShopIDFromContext(ctx)
case constants.UserTypePersonalCustomer:
buyerType = model.BuyerTypePersonal
buyerID = middleware.GetCustomerIDFromContext(ctx)
default:
return errors.New(errors.CodeForbidden, "不支持的用户类型")
}
if err := h.service.WalletPay(ctx, uint(id), buyerType, buyerID); err != nil {
return err
}
return response.Success(c, nil)
}

View File

@@ -0,0 +1,74 @@
package dto
import "time"
type CreateOrderRequest struct {
OrderType string `json:"order_type" validate:"required,oneof=single_card device" required:"true" description:"订单类型 (single_card:单卡购买, device:设备购买)"`
IotCardID *uint `json:"iot_card_id" validate:"required_if=OrderType single_card" description:"IoT卡ID(单卡购买时必填)"`
DeviceID *uint `json:"device_id" validate:"required_if=OrderType device" description:"设备ID(设备购买时必填)"`
PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"`
}
type OrderListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
PaymentStatus *int `json:"payment_status" query:"payment_status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)"`
OrderType string `json:"order_type" query:"order_type" validate:"omitempty,oneof=single_card device" description:"订单类型 (single_card:单卡购买, device:设备购买)"`
OrderNo string `json:"order_no" query:"order_no" validate:"omitempty,max=30" maxLength:"30" description:"订单号(精确查询)"`
StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"`
EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"`
}
type PayOrderRequest struct {
PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet wechat alipay" required:"true" description:"支付方式 (wallet:钱包支付, wechat:微信支付, alipay:支付宝支付)"`
}
type OrderItemResponse struct {
ID uint `json:"id" description:"明细ID"`
PackageID uint `json:"package_id" description:"套餐ID"`
PackageName string `json:"package_name" description:"套餐名称"`
Quantity int `json:"quantity" description:"数量"`
UnitPrice int64 `json:"unit_price" description:"单价(分)"`
Amount int64 `json:"amount" description:"小计金额(分)"`
}
type OrderResponse struct {
ID uint `json:"id" description:"订单ID"`
OrderNo string `json:"order_no" description:"订单号"`
OrderType string `json:"order_type" description:"订单类型 (single_card:单卡购买, device:设备购买)"`
BuyerType string `json:"buyer_type" description:"买家类型 (personal:个人客户, agent:代理商)"`
BuyerID uint `json:"buyer_id" description:"买家ID"`
IotCardID *uint `json:"iot_card_id,omitempty" description:"IoT卡ID"`
DeviceID *uint `json:"device_id,omitempty" description:"设备ID"`
TotalAmount int64 `json:"total_amount" description:"订单总金额(分)"`
PaymentMethod string `json:"payment_method,omitempty" description:"支付方式 (wallet:钱包支付, wechat:微信支付, alipay:支付宝支付)"`
PaymentStatus int `json:"payment_status" description:"支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)"`
PaymentStatusText string `json:"payment_status_text" description:"支付状态文本"`
PaidAt *time.Time `json:"paid_at,omitempty" description:"支付时间"`
CommissionStatus int `json:"commission_status" description:"佣金状态 (1:待计算, 2:已计算)"`
CommissionConfigVersion int `json:"commission_config_version" description:"佣金配置版本"`
Items []*OrderItemResponse `json:"items" description:"订单明细列表"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
}
type OrderListResponse struct {
List []*OrderResponse `json:"list" description:"订单列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页码"`
PageSize int `json:"page_size" description:"每页数量"`
TotalPages int `json:"total_pages" description:"总页数"`
}
type GetOrderRequest struct {
ID uint `path:"id" description:"订单ID" required:"true"`
}
type CancelOrderRequest struct {
ID uint `path:"id" description:"订单ID" required:"true"`
}
type PayOrderParams struct {
ID uint `path:"id" description:"订单ID" required:"true"`
PayOrderRequest
}

View File

@@ -3,35 +3,94 @@ package model
import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// Order 订单模型
// 支持两种订单类型:套餐订单(单卡/设备级)、号卡订单
// 记录套餐购买订单信息,支持单卡购买和设备购买两种类型
// 买家可以是个人客户(使用卡/设备钱包)或代理商(使用店铺钱包)
type Order struct {
gorm.Model
BaseModel `gorm:"embedded"`
OrderNo string `gorm:"column:order_no;type:varchar(100);uniqueIndex:idx_order_no,where:deleted_at IS NULL;not null;comment:订单号(唯一标识)" json:"order_no"`
OrderType int `gorm:"column:order_type;type:int;not null;comment:订单类型 1-套餐订单 2-号卡订单" json:"order_type"`
IotCardID uint `gorm:"column:iot_card_id;index;comment:IoT卡ID(单卡套餐订单时有值)" json:"iot_card_id"`
DeviceID uint `gorm:"column:device_id;index;comment:设备ID(设备级套餐订单时有值)" json:"device_id"`
NumberCardID uint `gorm:"column:number_card_id;index;comment:号卡ID(号卡订单时有值)" json:"number_card_id"`
PackageID uint `gorm:"column:package_id;index;comment:套餐ID(套餐订单时有值)" json:"package_id"`
UserID uint `gorm:"column:user_id;index;not null;comment:用户ID" json:"user_id"`
AgentID uint `gorm:"column:agent_id;index;comment:代理用户ID" json:"agent_id"`
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:订单金额(分为单位)" json:"amount"`
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);comment:支付方式 wallet-钱包 online-在线支付 carrier-运营商支付" json:"payment_method"`
WalletPaymentAmount int64 `gorm:"column:wallet_payment_amount;type:bigint;not null;default:0;comment:钱包支付金额(分)" json:"wallet_payment_amount"`
OnlinePaymentAmount int64 `gorm:"column:online_payment_amount;type:bigint;not null;default:0;comment:在线支付金额(分)" json:"online_payment_amount"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-待支付 2-已支付 3-已完成 4-已取消 5-已退款" json:"status"`
CarrierOrderID string `gorm:"column:carrier_order_id;type:varchar(255);comment:运营商订单ID" json:"carrier_order_id"`
CarrierOrderData datatypes.JSON `gorm:"column:carrier_order_data;type:jsonb;comment:运营商订单原始数据(JSON)" json:"carrier_order_data"`
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at"`
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"`
BaseModel `gorm:"embedded"`
// 订单基础信息
OrderNo string `gorm:"column:order_no;type:varchar(30);uniqueIndex:idx_order_no,where:deleted_at IS NULL;not null;comment:订单号(ORD+时间戳+6位随机数)" json:"order_no"`
OrderType string `gorm:"column:order_type;type:varchar(20);not null;comment:订单类型 single_card-单卡购买 device-设备购买" json:"order_type"`
// 买家信息
BuyerType string `gorm:"column:buyer_type;type:varchar(20);not null;comment:买家类型 personal-个人客户 agent-代理商" json:"buyer_type"`
BuyerID uint `gorm:"column:buyer_id;index:idx_order_buyer;not null;comment:买家ID(个人客户ID或店铺ID)" json:"buyer_id"`
// 关联资源
IotCardID *uint `gorm:"column:iot_card_id;index;comment:IoT卡ID(单卡购买时有值)" json:"iot_card_id,omitempty"`
DeviceID *uint `gorm:"column:device_id;index;comment:设备ID(设备购买时有值)" json:"device_id,omitempty"`
// 金额信息
TotalAmount int64 `gorm:"column:total_amount;type:bigint;not null;comment:订单总金额(分)" json:"total_amount"`
// 支付信息
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);comment:支付方式 wallet-钱包 wechat-微信 alipay-支付宝" json:"payment_method"`
PaymentStatus int `gorm:"column:payment_status;type:int;default:1;not null;index:idx_order_payment_status;comment:支付状态 1-待支付 2-已支付 3-已取消 4-已退款" json:"payment_status"`
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"`
// 佣金信息
CommissionStatus int `gorm:"column:commission_status;type:int;default:1;not null;comment:佣金状态 1-待计算 2-已计算" json:"commission_status"`
CommissionConfigVersion int `gorm:"column:commission_config_version;type:int;default:0;comment:佣金配置版本(订单创建时快照)" json:"commission_config_version"`
}
// TableName 指定表名
func (Order) TableName() string {
return "tb_order"
}
// 订单类型常量
const (
OrderTypeSingleCard = "single_card" // 单卡购买
OrderTypeDevice = "device" // 设备购买
)
// 买家类型常量
const (
BuyerTypePersonal = "personal" // 个人客户
BuyerTypeAgent = "agent" // 代理商
)
// 支付方式常量
const (
PaymentMethodWallet = "wallet" // 钱包支付
PaymentMethodWechat = "wechat" // 微信支付
PaymentMethodAlipay = "alipay" // 支付宝支付
)
// 支付状态常量
const (
PaymentStatusPending = 1 // 待支付
PaymentStatusPaid = 2 // 已支付
PaymentStatusCancelled = 3 // 已取消
PaymentStatusRefunded = 4 // 已退款
)
// 佣金状态常量
const (
CommissionStatusPending = 1 // 待计算
CommissionStatusCalculated = 2 // 已计算
)
// OrderItem 订单明细模型
// 记录订单中购买的套餐明细,支持一个订单购买多个套餐
type OrderItem struct {
gorm.Model
BaseModel `gorm:"embedded"`
OrderID uint `gorm:"column:order_id;index:idx_order_item_order_id;not null;comment:订单ID" json:"order_id"`
PackageID uint `gorm:"column:package_id;index;not null;comment:套餐ID" json:"package_id"`
PackageName string `gorm:"column:package_name;type:varchar(100);not null;comment:套餐名称(快照)" json:"package_name"`
Quantity int `gorm:"column:quantity;type:int;default:1;not null;comment:数量" json:"quantity"`
UnitPrice int64 `gorm:"column:unit_price;type:bigint;not null;comment:单价(分)" json:"unit_price"`
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:小计金额(分)" json:"amount"`
}
// TableName 指定表名
func (OrderItem) TableName() string {
return "tb_order_item"
}

View File

@@ -88,6 +88,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.ShopPackageBatchPricing != nil {
registerShopPackageBatchPricingRoutes(authGroup, handlers.ShopPackageBatchPricing, doc, basePath)
}
if handlers.AdminOrder != nil {
registerAdminOrderRoutes(authGroup, handlers.AdminOrder, doc, basePath)
}
}
func registerAdminAuthRoutes(router fiber.Router, handler interface{}, authMiddleware fiber.Handler, doc *openapi.Generator, basePath string) {

View File

@@ -13,6 +13,13 @@ func RegisterH5Routes(router fiber.Router, handlers *bootstrap.Handlers, middlew
if handlers.H5Auth != nil {
registerH5AuthRoutes(router, handlers.H5Auth, middlewares.H5Auth, doc, basePath)
}
// 需要认证的路由组
authGroup := router.Group("", middlewares.H5Auth)
if handlers.H5Order != nil {
registerH5OrderRoutes(authGroup, handlers.H5Order, doc, basePath)
}
}
func registerH5AuthRoutes(router fiber.Router, handler interface{}, authMiddleware fiber.Handler, doc *openapi.Generator, basePath string) {

100
internal/routes/order.go Normal file
View File

@@ -0,0 +1,100 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerAdminOrderRoutes 注册后台订单路由
func registerAdminOrderRoutes(router fiber.Router, handler *admin.OrderHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "POST", "/orders", handler.Create, RouteSpec{
Summary: "创建订单",
Tags: []string{"订单管理"},
Input: new(dto.CreateOrderRequest),
Output: new(dto.OrderResponse),
Auth: true,
})
Register(router, doc, basePath, "GET", "/orders", handler.List, RouteSpec{
Summary: "获取订单列表",
Tags: []string{"订单管理"},
Input: new(dto.OrderListRequest),
Output: new(dto.OrderListResponse),
Auth: true,
})
Register(router, doc, basePath, "GET", "/orders/:id", handler.Get, RouteSpec{
Summary: "获取订单详情",
Tags: []string{"订单管理"},
Input: new(dto.GetOrderRequest),
Output: new(dto.OrderResponse),
Auth: true,
})
Register(router, doc, basePath, "POST", "/orders/:id/cancel", handler.Cancel, RouteSpec{
Summary: "取消订单",
Tags: []string{"订单管理"},
Input: new(dto.CancelOrderRequest),
Output: nil,
Auth: true,
})
}
// registerH5OrderRoutes 注册H5订单路由
func registerH5OrderRoutes(router fiber.Router, handler *h5.OrderHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "POST", "/orders", handler.Create, RouteSpec{
Summary: "创建订单",
Tags: []string{"H5 订单"},
Input: new(dto.CreateOrderRequest),
Output: new(dto.OrderResponse),
Auth: true,
})
Register(router, doc, basePath, "GET", "/orders", handler.List, RouteSpec{
Summary: "获取订单列表",
Tags: []string{"H5 订单"},
Input: new(dto.OrderListRequest),
Output: new(dto.OrderListResponse),
Auth: true,
})
Register(router, doc, basePath, "GET", "/orders/:id", handler.Get, RouteSpec{
Summary: "获取订单详情",
Tags: []string{"H5 订单"},
Input: new(dto.GetOrderRequest),
Output: new(dto.OrderResponse),
Auth: true,
})
Register(router, doc, basePath, "POST", "/orders/:id/wallet-pay", handler.WalletPay, RouteSpec{
Summary: "钱包支付",
Tags: []string{"H5 订单"},
Input: new(dto.CancelOrderRequest),
Output: nil,
Auth: true,
})
}
// registerPaymentCallbackRoutes 注册支付回调路由
func registerPaymentCallbackRoutes(router fiber.Router, handler *callback.PaymentHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "POST", "/wechat-pay", handler.WechatPayCallback, RouteSpec{
Summary: "微信支付回调",
Tags: []string{"支付回调"},
Input: nil,
Output: nil,
Auth: false,
})
Register(router, doc, basePath, "POST", "/alipay", handler.AlipayCallback, RouteSpec{
Summary: "支付宝回调",
Tags: []string{"支付回调"},
Input: nil,
Output: nil,
Auth: false,
})
}

View File

@@ -31,4 +31,10 @@ func RegisterRoutesWithDoc(app *fiber.App, handlers *bootstrap.Handlers, middlew
// 4. 个人客户路由 (挂载在 /api/c/v1)
RegisterPersonalCustomerRoutes(app, handlers, middlewares.PersonalAuth)
// 5. 支付回调路由 (挂载在 /api/callback无需认证)
if handlers.PaymentCallback != nil {
callbackGroup := app.Group("/api/callback")
registerPaymentCallbackRoutes(callbackGroup, handlers.PaymentCallback, doc, "/api/callback")
}
}

View File

@@ -0,0 +1,420 @@
package order
import (
"context"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"gorm.io/gorm"
)
type Service struct {
db *gorm.DB
orderStore *postgres.OrderStore
orderItemStore *postgres.OrderItemStore
walletStore *postgres.WalletStore
purchaseValidationService *purchase_validation.Service
allocationConfigStore *postgres.ShopSeriesAllocationConfigStore
}
func New(
db *gorm.DB,
orderStore *postgres.OrderStore,
orderItemStore *postgres.OrderItemStore,
walletStore *postgres.WalletStore,
purchaseValidationService *purchase_validation.Service,
allocationConfigStore *postgres.ShopSeriesAllocationConfigStore,
) *Service {
return &Service{
db: db,
orderStore: orderStore,
orderItemStore: orderItemStore,
walletStore: walletStore,
purchaseValidationService: purchaseValidationService,
allocationConfigStore: allocationConfigStore,
}
}
func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) {
var validationResult *purchase_validation.PurchaseValidationResult
var err error
if req.OrderType == model.OrderTypeSingleCard {
if req.IotCardID == nil {
return nil, errors.New(errors.CodeInvalidParam, "单卡购买必须指定IoT卡ID")
}
validationResult, err = s.purchaseValidationService.ValidateCardPurchase(ctx, *req.IotCardID, req.PackageIDs)
} else if req.OrderType == model.OrderTypeDevice {
if req.DeviceID == nil {
return nil, errors.New(errors.CodeInvalidParam, "设备购买必须指定设备ID")
}
validationResult, err = s.purchaseValidationService.ValidateDevicePurchase(ctx, *req.DeviceID, req.PackageIDs)
} else {
return nil, errors.New(errors.CodeInvalidParam, "无效的订单类型")
}
if err != nil {
return nil, err
}
userID := middleware.GetUserIDFromContext(ctx)
configVersion := s.snapshotCommissionConfig(ctx, validationResult.Allocation.ID)
order := &model.Order{
BaseModel: model.BaseModel{
Creator: userID,
Updater: userID,
},
OrderNo: s.orderStore.GenerateOrderNo(),
OrderType: req.OrderType,
BuyerType: buyerType,
BuyerID: buyerID,
IotCardID: req.IotCardID,
DeviceID: req.DeviceID,
TotalAmount: validationResult.TotalPrice,
PaymentStatus: model.PaymentStatusPending,
CommissionStatus: model.CommissionStatusPending,
CommissionConfigVersion: configVersion,
}
var items []*model.OrderItem
for _, pkg := range validationResult.Packages {
item := &model.OrderItem{
BaseModel: model.BaseModel{
Creator: userID,
Updater: userID,
},
PackageID: pkg.ID,
PackageName: pkg.PackageName,
Quantity: 1,
UnitPrice: pkg.SuggestedRetailPrice,
Amount: pkg.SuggestedRetailPrice,
}
items = append(items, item)
}
if err := s.orderStore.Create(ctx, order, items); err != nil {
return nil, err
}
return s.buildOrderResponse(order, items), nil
}
func (s *Service) Get(ctx context.Context, id uint) (*dto.OrderResponse, error) {
order, items, err := s.orderStore.GetByIDWithItems(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "订单不存在")
}
return nil, err
}
return s.buildOrderResponse(order, items), nil
}
func (s *Service) List(ctx context.Context, req *dto.OrderListRequest, buyerType string, buyerID uint) (*dto.OrderListResponse, error) {
page := req.Page
pageSize := req.PageSize
if page == 0 {
page = 1
}
if pageSize == 0 {
pageSize = constants.DefaultPageSize
}
opts := &store.QueryOptions{
Page: page,
PageSize: pageSize,
}
filters := map[string]any{
"buyer_type": buyerType,
"buyer_id": buyerID,
}
if req.PaymentStatus != nil {
filters["payment_status"] = *req.PaymentStatus
}
if req.OrderType != "" {
filters["order_type"] = req.OrderType
}
if req.OrderNo != "" {
filters["order_no"] = req.OrderNo
}
if req.StartTime != nil {
filters["start_time"] = req.StartTime
}
if req.EndTime != nil {
filters["end_time"] = req.EndTime
}
orders, total, err := s.orderStore.List(ctx, opts, filters)
if err != nil {
return nil, err
}
var orderIDs []uint
for _, o := range orders {
orderIDs = append(orderIDs, o.ID)
}
itemsMap := make(map[uint][]*model.OrderItem)
if len(orderIDs) > 0 {
allItems, err := s.orderItemStore.ListByOrderIDs(ctx, orderIDs)
if err != nil {
return nil, err
}
for _, item := range allItems {
itemsMap[item.OrderID] = append(itemsMap[item.OrderID], item)
}
}
var list []*dto.OrderResponse
for _, o := range orders {
list = append(list, s.buildOrderResponse(o, itemsMap[o.ID]))
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
return &dto.OrderListResponse{
List: list,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}, nil
}
func (s *Service) Cancel(ctx context.Context, id uint, buyerType string, buyerID uint) error {
order, err := s.orderStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "订单不存在")
}
return err
}
if order.BuyerType != buyerType || order.BuyerID != buyerID {
return errors.New(errors.CodeForbidden, "无权操作此订单")
}
if order.PaymentStatus != model.PaymentStatusPending {
return errors.New(errors.CodeInvalidStatus, "只能取消待支付的订单")
}
return s.orderStore.UpdatePaymentStatus(ctx, id, model.PaymentStatusCancelled, nil)
}
func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) error {
order, err := s.orderStore.GetByID(ctx, orderID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "订单不存在")
}
return err
}
if order.BuyerType != buyerType || order.BuyerID != buyerID {
return errors.New(errors.CodeForbidden, "无权操作此订单")
}
if order.PaymentStatus != model.PaymentStatusPending {
return errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
}
var resourceType string
var resourceID uint
if buyerType == model.BuyerTypePersonal {
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
resourceType = "iot_card"
resourceID = *order.IotCardID
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
resourceType = "device"
resourceID = *order.DeviceID
} else {
return errors.New(errors.CodeInvalidParam, "无法确定钱包归属")
}
} else if buyerType == model.BuyerTypeAgent {
resourceType = "shop"
resourceID = buyerID
} else {
return errors.New(errors.CodeInvalidParam, "不支持的买家类型")
}
wallet, err := s.walletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID, "main")
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeWalletNotFound, "钱包不存在")
}
return err
}
if wallet.Balance < order.TotalAmount {
return errors.New(errors.CodeInsufficientBalance, "余额不足")
}
now := time.Now()
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.Wallet{}).
Where("id = ? AND balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version).
Updates(map[string]any{
"balance": gorm.Expr("balance - ?", order.TotalAmount),
"version": gorm.Expr("version + 1"),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
}
if err := tx.Model(&model.Order{}).Where("id = ?", orderID).Updates(map[string]any{
"payment_status": model.PaymentStatusPaid,
"payment_method": model.PaymentMethodWallet,
"paid_at": now,
}).Error; err != nil {
return err
}
return s.activatePackage(ctx, tx, order)
})
}
func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, paymentMethod string) error {
order, err := s.orderStore.GetByOrderNo(ctx, orderNo)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "订单不存在")
}
return err
}
if order.PaymentStatus == model.PaymentStatusPaid {
return nil
}
if order.PaymentStatus != model.PaymentStatusPending {
return errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
}
now := time.Now()
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Order{}).Where("id = ?", order.ID).Updates(map[string]any{
"payment_status": model.PaymentStatusPaid,
"payment_method": paymentMethod,
"paid_at": now,
}).Error; err != nil {
return err
}
return s.activatePackage(ctx, tx, order)
})
}
func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model.Order) error {
items, err := s.orderItemStore.ListByOrderID(ctx, order.ID)
if err != nil {
return err
}
now := time.Now()
for _, item := range items {
var pkg model.Package
if err := tx.First(&pkg, item.PackageID).Error; err != nil {
return err
}
usage := &model.PackageUsage{
BaseModel: model.BaseModel{
Creator: order.Creator,
Updater: order.Creator,
},
OrderID: order.ID,
PackageID: item.PackageID,
UsageType: order.OrderType,
DataLimitMB: pkg.DataAmountMB,
ActivatedAt: now,
ExpiresAt: now.AddDate(0, pkg.DurationMonths, 0),
Status: 1,
}
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
usage.IotCardID = *order.IotCardID
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
usage.DeviceID = *order.DeviceID
}
if err := tx.Create(usage).Error; err != nil {
return err
}
}
return nil
}
func (s *Service) snapshotCommissionConfig(ctx context.Context, allocationID uint) int {
if s.allocationConfigStore == nil {
return 0
}
config, err := s.allocationConfigStore.GetEffective(ctx, allocationID, time.Now())
if err != nil || config == nil {
return 0
}
return config.Version
}
func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderItem) *dto.OrderResponse {
var itemResponses []*dto.OrderItemResponse
for _, item := range items {
itemResponses = append(itemResponses, &dto.OrderItemResponse{
ID: item.ID,
PackageID: item.PackageID,
PackageName: item.PackageName,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
Amount: item.Amount,
})
}
statusText := ""
switch order.PaymentStatus {
case model.PaymentStatusPending:
statusText = "待支付"
case model.PaymentStatusPaid:
statusText = "已支付"
case model.PaymentStatusCancelled:
statusText = "已取消"
case model.PaymentStatusRefunded:
statusText = "已退款"
}
return &dto.OrderResponse{
ID: order.ID,
OrderNo: order.OrderNo,
OrderType: order.OrderType,
BuyerType: order.BuyerType,
BuyerID: order.BuyerID,
IotCardID: order.IotCardID,
DeviceID: order.DeviceID,
TotalAmount: order.TotalAmount,
PaymentMethod: order.PaymentMethod,
PaymentStatus: order.PaymentStatus,
PaymentStatusText: statusText,
PaidAt: order.PaidAt,
CommissionStatus: order.CommissionStatus,
CommissionConfigVersion: order.CommissionConfigVersion,
Items: itemResponses,
CreatedAt: order.CreatedAt,
UpdatedAt: order.UpdatedAt,
}
}

View File

@@ -0,0 +1,430 @@
package order
import (
"context"
"testing"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/tests/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type testEnv struct {
ctx context.Context
svc *Service
card *model.IotCard
device *model.Device
pkg *model.Package
shop *model.Shop
wallet *model.Wallet
allocation *model.ShopSeriesAllocation
}
func setupOrderTestEnv(t *testing.T) *testEnv {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
iotCardStore := postgres.NewIotCardStore(tx, rdb)
deviceStore := postgres.NewDeviceStore(tx, rdb)
packageStore := postgres.NewPackageStore(tx)
seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx)
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
carrierStore := postgres.NewCarrierStore(tx)
shopStore := postgres.NewShopStore(tx, rdb)
orderStore := postgres.NewOrderStore(tx, rdb)
orderItemStore := postgres.NewOrderItemStore(tx, rdb)
walletStore := postgres.NewWalletStore(tx, rdb)
ctx := context.Background()
carrier := &model.Carrier{
CarrierCode: "TEST_CARRIER_ORDER",
CarrierName: "测试运营商",
CarrierType: constants.CarrierTypeCMCC,
Status: constants.StatusEnabled,
}
require.NoError(t, carrierStore.Create(ctx, carrier))
shop := &model.Shop{
ShopName: "测试店铺ORDER",
ShopCode: "TEST_SHOP_ORDER",
Level: 1,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, shopStore.Create(ctx, shop))
series := &model.PackageSeries{
SeriesCode: "TEST_SERIES_ORDER",
SeriesName: "测试套餐系列",
Description: "测试用",
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, packageSeriesStore.Create(ctx, series))
allocation := &model.ShopSeriesAllocation{
ShopID: shop.ID,
SeriesID: series.ID,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, seriesAllocationStore.Create(ctx, allocation))
pkg := &model.Package{
PackageCode: "TEST_PKG_ORDER",
PackageName: "测试套餐",
SeriesID: series.ID,
PackageType: "formal",
DurationMonths: 1,
DataAmountMB: 1024,
SuggestedRetailPrice: 9900,
Status: constants.StatusEnabled,
ShelfStatus: constants.ShelfStatusOn,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, packageStore.Create(ctx, pkg))
shopIDPtr := &shop.ID
card := &model.IotCard{
ICCID: "89860000000000000002",
ShopID: shopIDPtr,
CarrierID: carrier.ID,
SeriesAllocationID: &allocation.ID,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, iotCardStore.Create(ctx, card))
device := &model.Device{
DeviceNo: "DEV_TEST_ORDER_001",
ShopID: shopIDPtr,
SeriesAllocationID: &allocation.ID,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, deviceStore.Create(ctx, device))
wallet := &model.Wallet{
ResourceType: "shop",
ResourceID: shop.ID,
WalletType: "main",
Balance: 100000,
Version: 1,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, tx.Create(wallet).Error)
purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore)
orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil)
userCtx := middleware.SetUserContext(ctx, &middleware.UserContextInfo{
UserID: 1,
UserType: constants.UserTypeAgent,
ShopID: shop.ID,
})
return &testEnv{
ctx: userCtx,
svc: orderSvc,
card: card,
device: device,
pkg: pkg,
shop: shop,
wallet: wallet,
allocation: allocation,
}
}
func TestOrderService_Create(t *testing.T) {
env := setupOrderTestEnv(t)
t.Run("创建单卡订单成功", func(t *testing.T) {
req := &dto.CreateOrderRequest{
OrderType: model.OrderTypeSingleCard,
IotCardID: &env.card.ID,
PackageIDs: []uint{env.pkg.ID},
}
resp, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
require.NoError(t, err)
assert.NotZero(t, resp.ID)
assert.Contains(t, resp.OrderNo, "ORD")
assert.Equal(t, model.OrderTypeSingleCard, resp.OrderType)
assert.Equal(t, model.BuyerTypeAgent, resp.BuyerType)
assert.Equal(t, env.shop.ID, resp.BuyerID)
assert.Equal(t, env.pkg.SuggestedRetailPrice, resp.TotalAmount)
assert.Equal(t, model.PaymentStatusPending, resp.PaymentStatus)
assert.Len(t, resp.Items, 1)
})
t.Run("创建设备订单成功", func(t *testing.T) {
req := &dto.CreateOrderRequest{
OrderType: model.OrderTypeDevice,
DeviceID: &env.device.ID,
PackageIDs: []uint{env.pkg.ID},
}
resp, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
require.NoError(t, err)
assert.NotZero(t, resp.ID)
assert.Equal(t, model.OrderTypeDevice, resp.OrderType)
})
t.Run("单卡订单缺少卡ID", func(t *testing.T) {
req := &dto.CreateOrderRequest{
OrderType: model.OrderTypeSingleCard,
PackageIDs: []uint{env.pkg.ID},
}
_, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok)
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
})
t.Run("设备订单缺少设备ID", func(t *testing.T) {
req := &dto.CreateOrderRequest{
OrderType: model.OrderTypeDevice,
PackageIDs: []uint{env.pkg.ID},
}
_, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok)
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
})
}
func TestOrderService_Get(t *testing.T) {
env := setupOrderTestEnv(t)
req := &dto.CreateOrderRequest{
OrderType: model.OrderTypeSingleCard,
IotCardID: &env.card.ID,
PackageIDs: []uint{env.pkg.ID},
}
created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
require.NoError(t, err)
t.Run("获取订单成功", func(t *testing.T) {
resp, err := env.svc.Get(env.ctx, created.ID)
require.NoError(t, err)
assert.Equal(t, created.OrderNo, resp.OrderNo)
assert.Len(t, resp.Items, 1)
})
t.Run("订单不存在", func(t *testing.T) {
_, err := env.svc.Get(env.ctx, 99999)
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok)
assert.Equal(t, errors.CodeNotFound, appErr.Code)
})
}
func TestOrderService_List(t *testing.T) {
env := setupOrderTestEnv(t)
for i := 0; i < 3; i++ {
req := &dto.CreateOrderRequest{
OrderType: model.OrderTypeSingleCard,
IotCardID: &env.card.ID,
PackageIDs: []uint{env.pkg.ID},
}
_, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
require.NoError(t, err)
}
t.Run("列表查询", func(t *testing.T) {
listReq := &dto.OrderListRequest{
Page: 1,
PageSize: 10,
}
resp, err := env.svc.List(env.ctx, listReq, model.BuyerTypeAgent, env.shop.ID)
require.NoError(t, err)
assert.GreaterOrEqual(t, resp.Total, int64(3))
assert.GreaterOrEqual(t, len(resp.List), 3)
})
t.Run("按支付状态过滤", func(t *testing.T) {
status := model.PaymentStatusPending
listReq := &dto.OrderListRequest{
Page: 1,
PageSize: 10,
PaymentStatus: &status,
}
resp, err := env.svc.List(env.ctx, listReq, model.BuyerTypeAgent, env.shop.ID)
require.NoError(t, err)
for _, o := range resp.List {
assert.Equal(t, model.PaymentStatusPending, o.PaymentStatus)
}
})
}
func TestOrderService_Cancel(t *testing.T) {
env := setupOrderTestEnv(t)
req := &dto.CreateOrderRequest{
OrderType: model.OrderTypeSingleCard,
IotCardID: &env.card.ID,
PackageIDs: []uint{env.pkg.ID},
}
created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
require.NoError(t, err)
t.Run("取消订单成功", func(t *testing.T) {
err := env.svc.Cancel(env.ctx, created.ID, model.BuyerTypeAgent, env.shop.ID)
require.NoError(t, err)
order, err := env.svc.Get(env.ctx, created.ID)
require.NoError(t, err)
assert.Equal(t, model.PaymentStatusCancelled, order.PaymentStatus)
})
t.Run("订单不存在", func(t *testing.T) {
err := env.svc.Cancel(env.ctx, 99999, model.BuyerTypeAgent, env.shop.ID)
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok)
assert.Equal(t, errors.CodeNotFound, appErr.Code)
})
t.Run("无权操作", func(t *testing.T) {
newReq := &dto.CreateOrderRequest{
OrderType: model.OrderTypeSingleCard,
IotCardID: &env.card.ID,
PackageIDs: []uint{env.pkg.ID},
}
newOrder, err := env.svc.Create(env.ctx, newReq, model.BuyerTypeAgent, env.shop.ID)
require.NoError(t, err)
err = env.svc.Cancel(env.ctx, newOrder.ID, model.BuyerTypeAgent, 99999)
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok)
assert.Equal(t, errors.CodeForbidden, appErr.Code)
})
}
func TestOrderService_WalletPay(t *testing.T) {
env := setupOrderTestEnv(t)
t.Run("钱包支付成功", func(t *testing.T) {
req := &dto.CreateOrderRequest{
OrderType: model.OrderTypeSingleCard,
IotCardID: &env.card.ID,
PackageIDs: []uint{env.pkg.ID},
}
created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
require.NoError(t, err)
err = env.svc.WalletPay(env.ctx, created.ID, model.BuyerTypeAgent, env.shop.ID)
require.NoError(t, err)
order, err := env.svc.Get(env.ctx, created.ID)
require.NoError(t, err)
assert.Equal(t, model.PaymentStatusPaid, order.PaymentStatus)
assert.Equal(t, model.PaymentMethodWallet, order.PaymentMethod)
assert.NotNil(t, order.PaidAt)
})
t.Run("订单不存在", func(t *testing.T) {
err := env.svc.WalletPay(env.ctx, 99999, model.BuyerTypeAgent, env.shop.ID)
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok)
assert.Equal(t, errors.CodeNotFound, appErr.Code)
})
t.Run("无权操作", func(t *testing.T) {
req := &dto.CreateOrderRequest{
OrderType: model.OrderTypeSingleCard,
IotCardID: &env.card.ID,
PackageIDs: []uint{env.pkg.ID},
}
created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
require.NoError(t, err)
err = env.svc.WalletPay(env.ctx, created.ID, model.BuyerTypeAgent, 99999)
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok)
assert.Equal(t, errors.CodeForbidden, appErr.Code)
})
t.Run("重复支付", func(t *testing.T) {
req := &dto.CreateOrderRequest{
OrderType: model.OrderTypeSingleCard,
IotCardID: &env.card.ID,
PackageIDs: []uint{env.pkg.ID},
}
created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
require.NoError(t, err)
err = env.svc.WalletPay(env.ctx, created.ID, model.BuyerTypeAgent, env.shop.ID)
require.NoError(t, err)
err = env.svc.WalletPay(env.ctx, created.ID, model.BuyerTypeAgent, env.shop.ID)
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok)
assert.Equal(t, errors.CodeInvalidStatus, appErr.Code)
})
}
func TestOrderService_HandlePaymentCallback(t *testing.T) {
env := setupOrderTestEnv(t)
t.Run("支付回调成功", func(t *testing.T) {
req := &dto.CreateOrderRequest{
OrderType: model.OrderTypeSingleCard,
IotCardID: &env.card.ID,
PackageIDs: []uint{env.pkg.ID},
}
created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
require.NoError(t, err)
err = env.svc.HandlePaymentCallback(env.ctx, created.OrderNo, model.PaymentMethodWechat)
require.NoError(t, err)
order, err := env.svc.Get(env.ctx, created.ID)
require.NoError(t, err)
assert.Equal(t, model.PaymentStatusPaid, order.PaymentStatus)
assert.Equal(t, model.PaymentMethodWechat, order.PaymentMethod)
})
t.Run("幂等处理-已支付订单", func(t *testing.T) {
req := &dto.CreateOrderRequest{
OrderType: model.OrderTypeSingleCard,
IotCardID: &env.card.ID,
PackageIDs: []uint{env.pkg.ID},
}
created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
require.NoError(t, err)
err = env.svc.HandlePaymentCallback(env.ctx, created.OrderNo, model.PaymentMethodAlipay)
require.NoError(t, err)
err = env.svc.HandlePaymentCallback(env.ctx, created.OrderNo, model.PaymentMethodAlipay)
require.NoError(t, err)
})
t.Run("订单不存在", func(t *testing.T) {
err := env.svc.HandlePaymentCallback(env.ctx, "NOT_EXISTS_ORDER_NO", model.PaymentMethodWechat)
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok)
assert.Equal(t, errors.CodeNotFound, appErr.Code)
})
}

View File

@@ -0,0 +1,159 @@
package purchase_validation
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
)
type Service struct {
db *gorm.DB
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
packageStore *postgres.PackageStore
seriesAllocationStore *postgres.ShopSeriesAllocationStore
}
func New(
db *gorm.DB,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
packageStore *postgres.PackageStore,
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
) *Service {
return &Service{
db: db,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
packageStore: packageStore,
seriesAllocationStore: seriesAllocationStore,
}
}
type PurchaseValidationResult struct {
Card *model.IotCard
Device *model.Device
Packages []*model.Package
TotalPrice int64
Allocation *model.ShopSeriesAllocation
}
func (s *Service) ValidateCardPurchase(ctx context.Context, cardID uint, packageIDs []uint) (*PurchaseValidationResult, error) {
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeIotCardNotFound, "IoT卡不存在")
}
return nil, err
}
if card.SeriesAllocationID == nil || *card.SeriesAllocationID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "该卡未关联套餐系列,无法购买套餐")
}
allocation, err := s.seriesAllocationStore.GetByID(ctx, *card.SeriesAllocationID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidParam, "套餐系列分配不存在")
}
return nil, err
}
if allocation.Status != constants.StatusEnabled {
return nil, errors.New(errors.CodeInvalidParam, "套餐系列分配已禁用")
}
packages, totalPrice, err := s.validatePackages(ctx, packageIDs, allocation.SeriesID)
if err != nil {
return nil, err
}
return &PurchaseValidationResult{
Card: card,
Packages: packages,
TotalPrice: totalPrice,
Allocation: allocation,
}, nil
}
func (s *Service) ValidateDevicePurchase(ctx context.Context, deviceID uint, packageIDs []uint) (*PurchaseValidationResult, error) {
device, err := s.deviceStore.GetByID(ctx, deviceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "设备不存在")
}
return nil, err
}
if device.SeriesAllocationID == nil || *device.SeriesAllocationID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "该设备未关联套餐系列,无法购买套餐")
}
allocation, err := s.seriesAllocationStore.GetByID(ctx, *device.SeriesAllocationID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidParam, "套餐系列分配不存在")
}
return nil, err
}
if allocation.Status != constants.StatusEnabled {
return nil, errors.New(errors.CodeInvalidParam, "套餐系列分配已禁用")
}
packages, totalPrice, err := s.validatePackages(ctx, packageIDs, allocation.SeriesID)
if err != nil {
return nil, err
}
return &PurchaseValidationResult{
Device: device,
Packages: packages,
TotalPrice: totalPrice,
Allocation: allocation,
}, nil
}
func (s *Service) validatePackages(ctx context.Context, packageIDs []uint, seriesID uint) ([]*model.Package, int64, error) {
if len(packageIDs) == 0 {
return nil, 0, errors.New(errors.CodeInvalidParam, "请选择至少一个套餐")
}
var packages []*model.Package
var totalPrice int64
for _, pkgID := range packageIDs {
pkg, err := s.packageStore.GetByID(ctx, pkgID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐不存在")
}
return nil, 0, err
}
if pkg.SeriesID != seriesID {
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐不在可购买范围内")
}
if pkg.Status != constants.StatusEnabled {
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐已禁用")
}
if pkg.ShelfStatus != constants.ShelfStatusOn {
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐已下架")
}
packages = append(packages, pkg)
totalPrice += pkg.SuggestedRetailPrice
}
return packages, totalPrice, nil
}
func (s *Service) GetPurchasePrice(ctx context.Context, pkg *model.Package, buyerType string) int64 {
return pkg.SuggestedRetailPrice
}

View File

@@ -0,0 +1,179 @@
package purchase_validation
import (
"context"
"testing"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/tests/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupTestData(t *testing.T) (context.Context, *Service, *model.IotCard, *model.Device, *model.Package, *model.ShopSeriesAllocation) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
iotCardStore := postgres.NewIotCardStore(tx, rdb)
deviceStore := postgres.NewDeviceStore(tx, rdb)
packageStore := postgres.NewPackageStore(tx)
seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx)
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
carrierStore := postgres.NewCarrierStore(tx)
shopStore := postgres.NewShopStore(tx, rdb)
ctx := context.Background()
carrier := &model.Carrier{
CarrierCode: "TEST_CARRIER_PV",
CarrierName: "测试运营商",
CarrierType: constants.CarrierTypeCMCC,
Status: constants.StatusEnabled,
}
require.NoError(t, carrierStore.Create(ctx, carrier))
shop := &model.Shop{
ShopName: "测试店铺PV",
ShopCode: "TEST_SHOP_PV",
Level: 1,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, shopStore.Create(ctx, shop))
series := &model.PackageSeries{
SeriesCode: "TEST_SERIES_PV",
SeriesName: "测试套餐系列",
Description: "测试用",
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, packageSeriesStore.Create(ctx, series))
allocation := &model.ShopSeriesAllocation{
ShopID: shop.ID,
SeriesID: series.ID,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, seriesAllocationStore.Create(ctx, allocation))
pkg := &model.Package{
PackageCode: "TEST_PKG_PV",
PackageName: "测试套餐",
SeriesID: series.ID,
SuggestedRetailPrice: 9900,
Status: constants.StatusEnabled,
ShelfStatus: constants.ShelfStatusOn,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, packageStore.Create(ctx, pkg))
shopIDPtr := &shop.ID
card := &model.IotCard{
ICCID: "89860000000000000001",
ShopID: shopIDPtr,
CarrierID: carrier.ID,
SeriesAllocationID: &allocation.ID,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, iotCardStore.Create(ctx, card))
device := &model.Device{
DeviceNo: "DEV_TEST_PV_001",
ShopID: shopIDPtr,
SeriesAllocationID: &allocation.ID,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, deviceStore.Create(ctx, device))
svc := New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore)
return ctx, svc, card, device, pkg, allocation
}
func TestPurchaseValidationService_ValidateCardPurchase(t *testing.T) {
ctx, svc, card, _, pkg, _ := setupTestData(t)
t.Run("验证成功", func(t *testing.T) {
result, err := svc.ValidateCardPurchase(ctx, card.ID, []uint{pkg.ID})
require.NoError(t, err)
assert.NotNil(t, result.Card)
assert.Equal(t, card.ID, result.Card.ID)
assert.Len(t, result.Packages, 1)
assert.Equal(t, pkg.SuggestedRetailPrice, result.TotalPrice)
})
t.Run("卡不存在", func(t *testing.T) {
_, err := svc.ValidateCardPurchase(ctx, 99999, []uint{pkg.ID})
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok)
assert.Equal(t, errors.CodeIotCardNotFound, appErr.Code)
})
t.Run("套餐列表为空", func(t *testing.T) {
_, err := svc.ValidateCardPurchase(ctx, card.ID, []uint{})
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok)
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
})
t.Run("套餐不存在", func(t *testing.T) {
_, err := svc.ValidateCardPurchase(ctx, card.ID, []uint{99999})
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok)
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
})
}
func TestPurchaseValidationService_ValidateDevicePurchase(t *testing.T) {
ctx, svc, _, device, pkg, _ := setupTestData(t)
t.Run("验证成功", func(t *testing.T) {
result, err := svc.ValidateDevicePurchase(ctx, device.ID, []uint{pkg.ID})
require.NoError(t, err)
assert.NotNil(t, result.Device)
assert.Equal(t, device.ID, result.Device.ID)
assert.Len(t, result.Packages, 1)
assert.Equal(t, pkg.SuggestedRetailPrice, result.TotalPrice)
})
t.Run("设备不存在", func(t *testing.T) {
_, err := svc.ValidateDevicePurchase(ctx, 99999, []uint{pkg.ID})
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok)
assert.Equal(t, errors.CodeNotFound, appErr.Code)
})
t.Run("套餐列表为空", func(t *testing.T) {
_, err := svc.ValidateDevicePurchase(ctx, device.ID, []uint{})
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok)
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
})
}
func TestPurchaseValidationService_GetPurchasePrice(t *testing.T) {
ctx, svc, _, _, pkg, _ := setupTestData(t)
t.Run("获取个人客户价格", func(t *testing.T) {
price := svc.GetPurchasePrice(ctx, pkg, model.BuyerTypePersonal)
assert.Equal(t, pkg.SuggestedRetailPrice, price)
})
t.Run("获取代理商价格", func(t *testing.T) {
price := svc.GetPurchasePrice(ctx, pkg, model.BuyerTypeAgent)
assert.Equal(t, pkg.SuggestedRetailPrice, price)
})
}

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)
}