Files
junhong_cmp_fiber/internal/service/order/service.go
huang 1290160728
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m22s
fix: 修复订单支付幂等性问题,防止重复激活套餐
- 使用条件更新实现支付状态原子转换(pending -> paid)
- 重复请求返回幂等成功,不再重复激活套餐
- 新增 tb_package_usage 唯一索引(order_id, package_id)
- 新增幂等性和异常状态测试,测试覆盖率 71.7%
- 归档 OpenSpec 变更 fix-order-activation-idempotency
2026-01-29 16:33:53 +08:00

532 lines
16 KiB
Go

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"
"github.com/break/junhong_cmp_fiber/pkg/queue"
"github.com/break/junhong_cmp_fiber/pkg/utils"
"github.com/bytedance/sonic"
"go.uber.org/zap"
"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
queueClient *queue.Client
logger *zap.Logger
}
func New(
db *gorm.DB,
orderStore *postgres.OrderStore,
orderItemStore *postgres.OrderItemStore,
walletStore *postgres.WalletStore,
purchaseValidationService *purchase_validation.Service,
allocationConfigStore *postgres.ShopSeriesAllocationConfigStore,
queueClient *queue.Client,
logger *zap.Logger,
) *Service {
return &Service{
db: db,
orderStore: orderStore,
orderItemStore: orderItemStore,
walletStore: walletStore,
purchaseValidationService: purchaseValidationService,
allocationConfigStore: allocationConfigStore,
queueClient: queueClient,
logger: logger,
}
}
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)
var seriesID *uint
var sellerShopID *uint
var sellerCostPrice int64
if validationResult.Allocation != nil {
seriesID = &validationResult.Allocation.SeriesID
sellerShopID = &validationResult.Allocation.ShopID
sellerCostPrice = utils.CalculateCostPrice(validationResult.Allocation, validationResult.TotalPrice)
}
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,
SeriesID: seriesID,
SellerShopID: sellerShopID,
SellerCostPrice: sellerCostPrice,
}
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, "无权操作此订单")
}
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()
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.Order{}).
Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending).
Updates(map[string]any{
"payment_status": model.PaymentStatusPaid,
"payment_method": model.PaymentMethodWallet,
"paid_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单支付状态失败")
}
if result.RowsAffected == 0 {
var currentOrder model.Order
if err := tx.First(&currentOrder, orderID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单失败")
}
switch currentOrder.PaymentStatus {
case model.PaymentStatusPaid:
return nil
case model.PaymentStatusCancelled:
return errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付")
case model.PaymentStatusRefunded:
return errors.New(errors.CodeInvalidStatus, "订单已退款,无法支付")
default:
return errors.New(errors.CodeInvalidStatus, "订单状态异常")
}
}
walletResult := 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 walletResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, walletResult.Error, "扣减钱包余额失败")
}
if walletResult.RowsAffected == 0 {
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
}
return s.activatePackage(ctx, tx, order)
})
if err != nil {
return err
}
s.enqueueCommissionCalculation(ctx, orderID)
return nil
}
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
}
now := time.Now()
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.Order{}).
Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPending).
Updates(map[string]any{
"payment_status": model.PaymentStatusPaid,
"payment_method": paymentMethod,
"paid_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单支付状态失败")
}
if result.RowsAffected == 0 {
var currentOrder model.Order
if err := tx.First(&currentOrder, order.ID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单失败")
}
switch currentOrder.PaymentStatus {
case model.PaymentStatusPaid:
return nil
case model.PaymentStatusCancelled:
return errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付")
case model.PaymentStatusRefunded:
return errors.New(errors.CodeInvalidStatus, "订单已退款,无法支付")
default:
return errors.New(errors.CodeInvalidStatus, "订单状态异常")
}
}
return s.activatePackage(ctx, tx, order)
})
if err != nil {
return err
}
s.enqueueCommissionCalculation(ctx, order.ID)
return nil
}
func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model.Order) error {
var items []*model.OrderItem
if err := tx.Where("order_id = ?", order.ID).Find(&items).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单明细失败")
}
now := time.Now()
for _, item := range items {
var existingUsage model.PackageUsage
err := tx.Where("order_id = ? AND package_id = ?", order.ID, item.PackageID).
First(&existingUsage).Error
if err == nil {
s.logger.Warn("套餐使用记录已存在,跳过创建",
zap.Uint("order_id", order.ID),
zap.Uint("package_id", item.PackageID))
continue
}
if err != gorm.ErrRecordNotFound {
return errors.Wrap(errors.CodeDatabaseError, err, "检查套餐使用记录失败")
}
var pkg model.Package
if err := tx.First(&pkg, item.PackageID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, 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 errors.Wrap(errors.CodeDatabaseError, 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) enqueueCommissionCalculation(ctx context.Context, orderID uint) {
if s.queueClient == nil {
s.logger.Warn("队列客户端未初始化,跳过佣金计算任务入队", zap.Uint("order_id", orderID))
return
}
payload := map[string]interface{}{
"order_id": orderID,
}
payloadBytes, err := sonic.Marshal(payload)
if err != nil {
s.logger.Error("佣金计算任务载荷序列化失败",
zap.Uint("order_id", orderID),
zap.Error(err))
return
}
if err := s.queueClient.EnqueueTask(ctx, constants.TaskTypeCommission, payloadBytes); err != nil {
s.logger.Error("佣金计算任务入队失败",
zap.Uint("order_id", orderID),
zap.Error(err),
zap.String("task_type", constants.TaskTypeCommission))
return
}
s.logger.Info("佣金计算任务已入队",
zap.Uint("order_id", orderID),
zap.String("task_type", constants.TaskTypeCommission))
}
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,
}
}