All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m40s
- 归档 OpenSpec 变更到 archive 目录 - 创建 2 个新的主规范文件:commission-trigger 和 order-commission-snapshot - 实现订单佣金快照字段和支付自动触发 - 确保事务一致性,所有佣金操作在同一事务内完成 - 提取成本价计算为公共工具函数
489 lines
14 KiB
Go
489 lines
14 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, "无权操作此订单")
|
|
}
|
|
|
|
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()
|
|
err = 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)
|
|
})
|
|
|
|
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
|
|
}
|
|
|
|
if order.PaymentStatus == model.PaymentStatusPaid {
|
|
return nil
|
|
}
|
|
|
|
if order.PaymentStatus != model.PaymentStatusPending {
|
|
return errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
|
|
}
|
|
|
|
now := time.Now()
|
|
err = 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)
|
|
})
|
|
|
|
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 {
|
|
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) 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,
|
|
}
|
|
}
|