All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m33s
**变更说明**: - 删除所有 *_test.go 文件(单元测试、集成测试、验收测试、流程测试) - 删除整个 tests/ 目录 - 更新 CLAUDE.md:用"测试禁令"章节替换所有测试要求 - 删除测试生成 Skill (openspec-generate-acceptance-tests) - 删除测试生成命令 (opsx:gen-tests) - 更新 tasks.md:删除所有测试相关任务 **新规范**: - ❌ 禁止编写任何形式的自动化测试 - ❌ 禁止创建 *_test.go 文件 - ❌ 禁止在任务中包含测试相关工作 - ✅ 仅当用户明确要求时才编写测试 **原因**: 业务系统的正确性通过人工验证和生产环境监控保证,测试代码维护成本高于价值。 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1080 lines
34 KiB
Go
1080 lines
34 KiB
Go
package order
|
||
|
||
import (
|
||
"context"
|
||
"time"
|
||
|
||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||
"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/wechat"
|
||
"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
|
||
shopPackageAllocationStore *postgres.ShopPackageAllocationStore
|
||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||
iotCardStore *postgres.IotCardStore
|
||
deviceStore *postgres.DeviceStore
|
||
packageSeriesStore *postgres.PackageSeriesStore
|
||
packageUsageStore *postgres.PackageUsageStore
|
||
packageStore *postgres.PackageStore
|
||
wechatPayment wechat.PaymentServiceInterface
|
||
queueClient *queue.Client
|
||
logger *zap.Logger
|
||
}
|
||
|
||
func New(
|
||
db *gorm.DB,
|
||
orderStore *postgres.OrderStore,
|
||
orderItemStore *postgres.OrderItemStore,
|
||
walletStore *postgres.WalletStore,
|
||
purchaseValidationService *purchase_validation.Service,
|
||
shopPackageAllocationStore *postgres.ShopPackageAllocationStore,
|
||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||
iotCardStore *postgres.IotCardStore,
|
||
deviceStore *postgres.DeviceStore,
|
||
packageSeriesStore *postgres.PackageSeriesStore,
|
||
packageUsageStore *postgres.PackageUsageStore,
|
||
packageStore *postgres.PackageStore,
|
||
wechatPayment wechat.PaymentServiceInterface,
|
||
queueClient *queue.Client,
|
||
logger *zap.Logger,
|
||
) *Service {
|
||
return &Service{
|
||
db: db,
|
||
orderStore: orderStore,
|
||
orderItemStore: orderItemStore,
|
||
walletStore: walletStore,
|
||
purchaseValidationService: purchaseValidationService,
|
||
shopPackageAllocationStore: shopPackageAllocationStore,
|
||
shopSeriesAllocationStore: shopSeriesAllocationStore,
|
||
iotCardStore: iotCardStore,
|
||
deviceStore: deviceStore,
|
||
packageSeriesStore: packageSeriesStore,
|
||
packageUsageStore: packageUsageStore,
|
||
packageStore: packageStore,
|
||
wechatPayment: wechatPayment,
|
||
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
|
||
}
|
||
|
||
forceRechargeCheck := s.checkForceRechargeRequirement(ctx, validationResult)
|
||
if forceRechargeCheck.NeedForceRecharge && validationResult.TotalPrice < forceRechargeCheck.ForceRechargeAmount {
|
||
return nil, errors.New(errors.CodeForceRechargeRequired, "首次购买需满足最低充值要求")
|
||
}
|
||
|
||
userID := middleware.GetUserIDFromContext(ctx)
|
||
orderBuyerType := buyerType
|
||
orderBuyerID := buyerID
|
||
totalAmount := validationResult.TotalPrice
|
||
paymentMethod := req.PaymentMethod
|
||
paymentStatus := model.PaymentStatusPending
|
||
var paidAt *time.Time
|
||
isPurchaseOnBehalf := false
|
||
|
||
var seriesID *uint
|
||
var sellerShopID *uint
|
||
var sellerCostPrice int64
|
||
|
||
if validationResult.Card != nil {
|
||
seriesID = validationResult.Card.SeriesID
|
||
sellerShopID = validationResult.Card.ShopID
|
||
} else if validationResult.Device != nil {
|
||
seriesID = validationResult.Device.SeriesID
|
||
sellerShopID = validationResult.Device.ShopID
|
||
}
|
||
|
||
if sellerShopID != nil && len(validationResult.Packages) > 0 {
|
||
firstPackageID := validationResult.Packages[0].ID
|
||
allocation, err := s.shopPackageAllocationStore.GetByShopAndPackage(ctx, *sellerShopID, firstPackageID)
|
||
if err == nil && allocation != nil {
|
||
sellerCostPrice = allocation.CostPrice
|
||
}
|
||
}
|
||
|
||
if req.PaymentMethod == model.PaymentMethodOffline {
|
||
purchaseBuyerID, buyerCostPrice, purchasePaidAt, err := s.resolvePurchaseOnBehalfInfo(ctx, validationResult)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
orderBuyerType = model.BuyerTypeAgent
|
||
orderBuyerID = purchaseBuyerID
|
||
totalAmount = buyerCostPrice
|
||
paymentMethod = model.PaymentMethodOffline
|
||
paymentStatus = model.PaymentStatusPaid
|
||
paidAt = purchasePaidAt
|
||
isPurchaseOnBehalf = true
|
||
sellerCostPrice = buyerCostPrice
|
||
}
|
||
|
||
order := &model.Order{
|
||
BaseModel: model.BaseModel{
|
||
Creator: userID,
|
||
Updater: userID,
|
||
},
|
||
OrderNo: s.orderStore.GenerateOrderNo(),
|
||
OrderType: req.OrderType,
|
||
BuyerType: orderBuyerType,
|
||
BuyerID: orderBuyerID,
|
||
IotCardID: req.IotCardID,
|
||
DeviceID: req.DeviceID,
|
||
TotalAmount: totalAmount,
|
||
PaymentMethod: paymentMethod,
|
||
PaymentStatus: paymentStatus,
|
||
PaidAt: paidAt,
|
||
CommissionStatus: model.CommissionStatusPending,
|
||
CommissionConfigVersion: 0,
|
||
SeriesID: seriesID,
|
||
SellerShopID: sellerShopID,
|
||
SellerCostPrice: sellerCostPrice,
|
||
IsPurchaseOnBehalf: isPurchaseOnBehalf,
|
||
}
|
||
|
||
items := s.buildOrderItems(userID, validationResult.Packages)
|
||
|
||
if req.PaymentMethod == model.PaymentMethodOffline {
|
||
if err := s.createOrderWithActivation(ctx, order, items); err != nil {
|
||
return nil, err
|
||
}
|
||
s.enqueueCommissionCalculation(ctx, order.ID)
|
||
return s.buildOrderResponse(order, items), nil
|
||
}
|
||
|
||
if err := s.orderStore.Create(ctx, order, items); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return s.buildOrderResponse(order, items), nil
|
||
}
|
||
|
||
func (s *Service) resolvePurchaseOnBehalfInfo(ctx context.Context, result *purchase_validation.PurchaseValidationResult) (uint, int64, *time.Time, error) {
|
||
var resourceShopID *uint
|
||
var seriesID *uint
|
||
|
||
if result.Card != nil {
|
||
resourceShopID = result.Card.ShopID
|
||
seriesID = result.Card.SeriesID
|
||
} else if result.Device != nil {
|
||
resourceShopID = result.Device.ShopID
|
||
seriesID = result.Device.SeriesID
|
||
}
|
||
|
||
if resourceShopID == nil || *resourceShopID == 0 {
|
||
return 0, 0, nil, errors.New(errors.CodeInvalidParam, "资源未分配给代理商,无法代购")
|
||
}
|
||
|
||
if seriesID == nil || *seriesID == 0 {
|
||
return 0, 0, nil, errors.New(errors.CodeInvalidParam, "资源未关联套餐系列")
|
||
}
|
||
|
||
if len(result.Packages) == 0 {
|
||
return 0, 0, nil, errors.New(errors.CodeInvalidParam, "订单中没有套餐")
|
||
}
|
||
|
||
firstPackageID := result.Packages[0].ID
|
||
buyerAllocation, err := s.shopPackageAllocationStore.GetByShopAndPackage(ctx, *resourceShopID, firstPackageID)
|
||
if err != nil {
|
||
return 0, 0, nil, errors.New(errors.CodeInvalidParam, "买家没有该套餐的分配配置")
|
||
}
|
||
|
||
now := time.Now()
|
||
return *resourceShopID, buyerAllocation.CostPrice, &now, nil
|
||
}
|
||
|
||
func (s *Service) buildOrderItems(operatorID uint, packages []*model.Package) []*model.OrderItem {
|
||
items := make([]*model.OrderItem, 0, len(packages))
|
||
for _, pkg := range packages {
|
||
item := &model.OrderItem{
|
||
BaseModel: model.BaseModel{
|
||
Creator: operatorID,
|
||
Updater: operatorID,
|
||
},
|
||
PackageID: pkg.ID,
|
||
PackageName: pkg.PackageName,
|
||
Quantity: 1,
|
||
UnitPrice: pkg.SuggestedRetailPrice,
|
||
Amount: pkg.SuggestedRetailPrice,
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
|
||
return items
|
||
}
|
||
|
||
func (s *Service) createOrderWithActivation(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 errors.Wrap(errors.CodeDatabaseError, err, "创建代购订单失败")
|
||
}
|
||
|
||
for _, item := range items {
|
||
item.OrderID = order.ID
|
||
}
|
||
if err := tx.CreateInBatches(items, 100).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败")
|
||
}
|
||
|
||
return s.activatePackage(ctx, tx, order)
|
||
})
|
||
}
|
||
|
||
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.IsPurchaseOnBehalf {
|
||
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.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(¤tOrder, 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(¤tOrder, 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, "查询订单明细失败")
|
||
}
|
||
|
||
// 任务 8.1: 检查混买限制 - 禁止同订单混买正式套餐和加油包
|
||
if err := s.validatePackageTypeMix(tx, items); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 确定载体类型和ID
|
||
carrierType := "iot_card"
|
||
var carrierID uint
|
||
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
|
||
carrierID = *order.IotCardID
|
||
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
|
||
carrierType = "device"
|
||
carrierID = *order.DeviceID
|
||
} else {
|
||
return errors.New(errors.CodeInvalidParam, "无效的订单类型或缺少载体ID")
|
||
}
|
||
|
||
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, "查询套餐信息失败")
|
||
}
|
||
|
||
// 根据套餐类型分别处理
|
||
if pkg.PackageType == "formal" {
|
||
// 主套餐处理逻辑(任务 8.2-8.4)
|
||
if err := s.activateMainPackage(ctx, tx, order, &pkg, carrierType, carrierID, now); err != nil {
|
||
return err
|
||
}
|
||
} else if pkg.PackageType == "addon" {
|
||
// 加油包处理逻辑(任务 8.5-8.7)
|
||
if err := s.activateAddonPackage(ctx, tx, order, &pkg, carrierType, carrierID, now); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// validatePackageTypeMix 任务 8.1: 检查混买限制
|
||
func (s *Service) validatePackageTypeMix(tx *gorm.DB, items []*model.OrderItem) error {
|
||
hasFormal := false
|
||
hasAddon := false
|
||
|
||
for _, item := range items {
|
||
var pkg model.Package
|
||
if err := tx.First(&pkg, item.PackageID).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
|
||
}
|
||
|
||
if pkg.PackageType == "formal" {
|
||
hasFormal = true
|
||
} else if pkg.PackageType == "addon" {
|
||
hasAddon = true
|
||
}
|
||
|
||
if hasFormal && hasAddon {
|
||
return errors.New(errors.CodeInvalidParam, "不允许在同一订单中同时购买正式套餐和加油包")
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// activateMainPackage 任务 8.2-8.4: 主套餐激活逻辑
|
||
func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *model.Order, pkg *model.Package, carrierType string, carrierID uint, now time.Time) error {
|
||
// 检查是否有生效中主套餐
|
||
var activeMainPackage model.PackageUsage
|
||
err := tx.Where("status = ?", constants.PackageUsageStatusActive).
|
||
Where("master_usage_id IS NULL").
|
||
Where(carrierType+"_id = ?", carrierID).
|
||
Order("priority ASC").
|
||
First(&activeMainPackage).Error
|
||
|
||
hasActiveMain := err == nil
|
||
|
||
var status int
|
||
var priority int
|
||
var activatedAt time.Time
|
||
var expiresAt time.Time
|
||
var nextResetAt *time.Time
|
||
var pendingRealnameActivation bool
|
||
|
||
if hasActiveMain {
|
||
// 任务 8.3: 有生效中主套餐,新套餐排队
|
||
status = constants.PackageUsageStatusPending
|
||
// 查询当前最大优先级
|
||
var maxPriority int
|
||
tx.Model(&model.PackageUsage{}).
|
||
Where(carrierType+"_id = ?", carrierID).
|
||
Select("COALESCE(MAX(priority), 0)").
|
||
Scan(&maxPriority)
|
||
priority = maxPriority + 1
|
||
// 排队套餐暂不设置激活时间和过期时间(由激活任务处理)
|
||
} else {
|
||
// 任务 8.4: 无生效中主套餐,立即激活
|
||
status = constants.PackageUsageStatusActive
|
||
priority = 1
|
||
activatedAt = now
|
||
// 使用工具函数计算过期时间
|
||
expiresAt = packagepkg.CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
||
// 计算下次重置时间
|
||
// TODO: 从运营商表读取 billing_day(任务 1.5 待实现)
|
||
// 暂时使用默认值:联通=27,其他=1
|
||
billingDay := 1 // 默认1号计费
|
||
if carrierType == "iot_card" {
|
||
var card model.IotCard
|
||
if err := tx.First(&card, carrierID).Error; err == nil {
|
||
var carrier model.Carrier
|
||
if err := tx.First(&carrier, card.CarrierID).Error; err == nil {
|
||
if carrier.CarrierType == "CUCC" {
|
||
billingDay = 27 // 联通27号计费
|
||
}
|
||
}
|
||
}
|
||
}
|
||
nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, now, billingDay)
|
||
}
|
||
|
||
// 任务 8.9: 后台囤货场景
|
||
if pkg.EnableRealnameActivation {
|
||
// 需要实名后才能激活
|
||
status = constants.PackageUsageStatusPending
|
||
pendingRealnameActivation = true
|
||
}
|
||
|
||
// 创建套餐使用记录
|
||
usage := &model.PackageUsage{
|
||
BaseModel: model.BaseModel{
|
||
Creator: order.Creator,
|
||
Updater: order.Creator,
|
||
},
|
||
OrderID: order.ID,
|
||
PackageID: pkg.ID,
|
||
UsageType: order.OrderType,
|
||
DataLimitMB: pkg.RealDataMB,
|
||
Status: status,
|
||
Priority: priority,
|
||
DataResetCycle: pkg.DataResetCycle,
|
||
PendingRealnameActivation: pendingRealnameActivation,
|
||
}
|
||
|
||
if carrierType == "iot_card" {
|
||
usage.IotCardID = carrierID
|
||
} else {
|
||
usage.DeviceID = carrierID
|
||
}
|
||
|
||
if status == constants.PackageUsageStatusActive {
|
||
usage.ActivatedAt = activatedAt
|
||
usage.ExpiresAt = expiresAt
|
||
usage.NextResetAt = nextResetAt
|
||
}
|
||
|
||
// 创建套餐使用记录(两步处理零值问题)
|
||
if err := tx.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "创建主套餐使用记录失败")
|
||
}
|
||
// 明确更新零值字段
|
||
if err := tx.Model(usage).Updates(map[string]interface{}{
|
||
"status": usage.Status,
|
||
"pending_realname_activation": usage.PendingRealnameActivation,
|
||
}).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "更新主套餐状态失败")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// activateAddonPackage 任务 8.5-8.7: 加油包激活逻辑
|
||
func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order *model.Order, pkg *model.Package, carrierType string, carrierID uint, now time.Time) error {
|
||
// 任务 8.5-8.6: 检查是否有主套餐(status IN (0,1))
|
||
var mainPackage model.PackageUsage
|
||
err := tx.Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive}).
|
||
Where("master_usage_id IS NULL").
|
||
Where(carrierType+"_id = ?", carrierID).
|
||
Order("priority ASC").
|
||
First(&mainPackage).Error
|
||
|
||
if err == gorm.ErrRecordNotFound {
|
||
return errors.New(errors.CodeInvalidParam, "必须有主套餐才能购买加油包")
|
||
}
|
||
if err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐失败")
|
||
}
|
||
|
||
// 任务 8.7: 创建加油包,绑定到主套餐
|
||
// 查询当前最大优先级(加油包优先级低于主套餐)
|
||
var maxPriority int
|
||
tx.Model(&model.PackageUsage{}).
|
||
Where(carrierType+"_id = ?", carrierID).
|
||
Select("COALESCE(MAX(priority), 0)").
|
||
Scan(&maxPriority)
|
||
priority := maxPriority + 1
|
||
|
||
// 加油包立即生效
|
||
status := constants.PackageUsageStatusActive
|
||
activatedAt := now
|
||
|
||
// 计算过期时间(根据 has_independent_expiry)
|
||
var expiresAt time.Time
|
||
// 注意:has_independent_expiry 字段在 Package 模型中,暂时使用默认行为
|
||
// 默认加油包跟随主套餐过期
|
||
expiresAt = mainPackage.ExpiresAt
|
||
|
||
usage := &model.PackageUsage{
|
||
BaseModel: model.BaseModel{
|
||
Creator: order.Creator,
|
||
Updater: order.Creator,
|
||
},
|
||
OrderID: order.ID,
|
||
PackageID: pkg.ID,
|
||
UsageType: order.OrderType,
|
||
DataLimitMB: pkg.RealDataMB,
|
||
Status: status,
|
||
Priority: priority,
|
||
MasterUsageID: &mainPackage.ID,
|
||
ActivatedAt: activatedAt,
|
||
ExpiresAt: expiresAt,
|
||
DataResetCycle: pkg.DataResetCycle,
|
||
}
|
||
|
||
if carrierType == "iot_card" {
|
||
usage.IotCardID = carrierID
|
||
} else {
|
||
usage.DeviceID = carrierID
|
||
}
|
||
|
||
// 创建加油包使用记录(加油包 status=1,不需要处理零值)
|
||
if err := tx.Create(usage).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "创建加油包使用记录失败")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
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,
|
||
IsPurchaseOnBehalf: order.IsPurchaseOnBehalf,
|
||
CommissionStatus: order.CommissionStatus,
|
||
CommissionConfigVersion: order.CommissionConfigVersion,
|
||
Items: itemResponses,
|
||
CreatedAt: order.CreatedAt,
|
||
UpdatedAt: order.UpdatedAt,
|
||
}
|
||
}
|
||
|
||
// WechatPayJSAPI 发起微信 JSAPI 支付
|
||
func (s *Service) WechatPayJSAPI(ctx context.Context, orderID uint, openID string, buyerType string, buyerID uint) (*dto.WechatPayJSAPIResponse, error) {
|
||
if s.wechatPayment == nil {
|
||
s.logger.Error("微信支付服务未配置")
|
||
return nil, errors.New(errors.CodeWechatPayFailed, "微信支付服务未配置")
|
||
}
|
||
|
||
order, err := s.orderStore.GetByID(ctx, orderID)
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, errors.New(errors.CodeNotFound, "订单不存在")
|
||
}
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询订单失败")
|
||
}
|
||
|
||
if order.BuyerType != buyerType || order.BuyerID != buyerID {
|
||
return nil, errors.New(errors.CodeForbidden, "无权操作此订单")
|
||
}
|
||
|
||
if order.PaymentStatus != model.PaymentStatusPending {
|
||
return nil, errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
|
||
}
|
||
|
||
items, err := s.orderItemStore.ListByOrderIDs(ctx, []uint{orderID})
|
||
if err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询订单明细失败")
|
||
}
|
||
description := "套餐购买"
|
||
if len(items) > 0 {
|
||
description = items[0].PackageName
|
||
}
|
||
|
||
result, err := s.wechatPayment.CreateJSAPIOrder(ctx, order.OrderNo, description, openID, int(order.TotalAmount))
|
||
if err != nil {
|
||
s.logger.Error("创建 JSAPI 支付失败",
|
||
zap.Uint("order_id", orderID),
|
||
zap.String("order_no", order.OrderNo),
|
||
zap.Error(err),
|
||
)
|
||
return nil, err
|
||
}
|
||
|
||
s.logger.Info("创建 JSAPI 支付成功",
|
||
zap.Uint("order_id", orderID),
|
||
zap.String("order_no", order.OrderNo),
|
||
zap.String("prepay_id", result.PrepayID),
|
||
)
|
||
|
||
payConfig, _ := result.PayConfig.(map[string]interface{})
|
||
return &dto.WechatPayJSAPIResponse{
|
||
PrepayID: result.PrepayID,
|
||
PayConfig: payConfig,
|
||
}, nil
|
||
}
|
||
|
||
// WechatPayH5 发起微信 H5 支付
|
||
func (s *Service) WechatPayH5(ctx context.Context, orderID uint, sceneInfo *dto.WechatH5SceneInfo, buyerType string, buyerID uint) (*dto.WechatPayH5Response, error) {
|
||
if s.wechatPayment == nil {
|
||
s.logger.Error("微信支付服务未配置")
|
||
return nil, errors.New(errors.CodeWechatPayFailed, "微信支付服务未配置")
|
||
}
|
||
|
||
order, err := s.orderStore.GetByID(ctx, orderID)
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, errors.New(errors.CodeNotFound, "订单不存在")
|
||
}
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询订单失败")
|
||
}
|
||
|
||
if order.BuyerType != buyerType || order.BuyerID != buyerID {
|
||
return nil, errors.New(errors.CodeForbidden, "无权操作此订单")
|
||
}
|
||
|
||
if order.PaymentStatus != model.PaymentStatusPending {
|
||
return nil, errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
|
||
}
|
||
|
||
items, err := s.orderItemStore.ListByOrderIDs(ctx, []uint{orderID})
|
||
if err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询订单明细失败")
|
||
}
|
||
description := "套餐购买"
|
||
if len(items) > 0 {
|
||
description = items[0].PackageName
|
||
}
|
||
|
||
h5SceneInfo := &wechat.H5SceneInfo{
|
||
PayerClientIP: sceneInfo.PayerClientIP,
|
||
H5Type: sceneInfo.H5Info.Type,
|
||
}
|
||
|
||
result, err := s.wechatPayment.CreateH5Order(ctx, order.OrderNo, description, int(order.TotalAmount), h5SceneInfo)
|
||
if err != nil {
|
||
s.logger.Error("创建 H5 支付失败",
|
||
zap.Uint("order_id", orderID),
|
||
zap.String("order_no", order.OrderNo),
|
||
zap.Error(err),
|
||
)
|
||
return nil, err
|
||
}
|
||
|
||
s.logger.Info("创建 H5 支付成功",
|
||
zap.Uint("order_id", orderID),
|
||
zap.String("order_no", order.OrderNo),
|
||
zap.String("h5_url", result.H5URL),
|
||
)
|
||
|
||
return &dto.WechatPayH5Response{
|
||
H5URL: result.H5URL,
|
||
}, nil
|
||
}
|
||
|
||
type ForceRechargeRequirement struct {
|
||
NeedForceRecharge bool
|
||
ForceRechargeAmount int64
|
||
TriggerType string
|
||
}
|
||
|
||
// checkForceRechargeRequirement 检查强充要求
|
||
// 从 PackageSeries 获取一次性佣金配置,使用 per-series 追踪判断是否需要强充
|
||
func (s *Service) checkForceRechargeRequirement(ctx context.Context, result *purchase_validation.PurchaseValidationResult) *ForceRechargeRequirement {
|
||
defaultResult := &ForceRechargeRequirement{NeedForceRecharge: false}
|
||
|
||
// 1. 获取 seriesID
|
||
var seriesID *uint
|
||
var firstCommissionPaid bool
|
||
|
||
if result.Card != nil {
|
||
seriesID = result.Card.SeriesID
|
||
if seriesID != nil {
|
||
firstCommissionPaid = result.Card.IsFirstRechargeTriggeredBySeries(*seriesID)
|
||
}
|
||
} else if result.Device != nil {
|
||
seriesID = result.Device.SeriesID
|
||
if seriesID != nil {
|
||
firstCommissionPaid = result.Device.IsFirstRechargeTriggeredBySeries(*seriesID)
|
||
}
|
||
}
|
||
|
||
if seriesID == nil {
|
||
return defaultResult
|
||
}
|
||
|
||
// 2. 从 PackageSeries 获取一次性佣金配置
|
||
series, err := s.packageSeriesStore.GetByID(ctx, *seriesID)
|
||
if err != nil {
|
||
s.logger.Warn("查询套餐系列失败", zap.Uint("series_id", *seriesID), zap.Error(err))
|
||
return defaultResult
|
||
}
|
||
|
||
config, err := series.GetOneTimeCommissionConfig()
|
||
if err != nil || config == nil || !config.Enable {
|
||
return defaultResult
|
||
}
|
||
|
||
// 3. 如果该系列的一次性佣金已发放,无需强充
|
||
if firstCommissionPaid {
|
||
return defaultResult
|
||
}
|
||
|
||
// 4. 根据触发类型判断是否需要强充
|
||
if config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge {
|
||
return &ForceRechargeRequirement{
|
||
NeedForceRecharge: true,
|
||
ForceRechargeAmount: config.Threshold,
|
||
TriggerType: model.OneTimeCommissionTriggerFirstRecharge,
|
||
}
|
||
}
|
||
|
||
// 5. 累计充值模式,检查是否启用强充
|
||
if config.EnableForceRecharge {
|
||
forceAmount := config.ForceAmount
|
||
if forceAmount == 0 {
|
||
forceAmount = config.Threshold
|
||
}
|
||
return &ForceRechargeRequirement{
|
||
NeedForceRecharge: true,
|
||
ForceRechargeAmount: forceAmount,
|
||
TriggerType: model.OneTimeCommissionTriggerAccumulatedRecharge,
|
||
}
|
||
}
|
||
|
||
return defaultResult
|
||
}
|
||
|
||
func (s *Service) GetPurchaseCheck(ctx context.Context, req *dto.PurchaseCheckRequest) (*dto.PurchaseCheckResponse, error) {
|
||
var validationResult *purchase_validation.PurchaseValidationResult
|
||
var err error
|
||
|
||
if req.OrderType == model.OrderTypeSingleCard {
|
||
validationResult, err = s.purchaseValidationService.ValidateCardPurchase(ctx, req.ResourceID, req.PackageIDs)
|
||
} else if req.OrderType == model.OrderTypeDevice {
|
||
validationResult, err = s.purchaseValidationService.ValidateDevicePurchase(ctx, req.ResourceID, req.PackageIDs)
|
||
} else {
|
||
return nil, errors.New(errors.CodeInvalidParam, "无效的订单类型")
|
||
}
|
||
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
forceRechargeCheck := s.checkForceRechargeRequirement(ctx, validationResult)
|
||
|
||
response := &dto.PurchaseCheckResponse{
|
||
TotalPackageAmount: validationResult.TotalPrice,
|
||
NeedForceRecharge: forceRechargeCheck.NeedForceRecharge,
|
||
ForceRechargeAmount: forceRechargeCheck.ForceRechargeAmount,
|
||
ActualPayment: validationResult.TotalPrice,
|
||
WalletCredit: validationResult.TotalPrice,
|
||
}
|
||
|
||
if forceRechargeCheck.NeedForceRecharge {
|
||
if validationResult.TotalPrice < forceRechargeCheck.ForceRechargeAmount {
|
||
response.ActualPayment = forceRechargeCheck.ForceRechargeAmount
|
||
response.WalletCredit = forceRechargeCheck.ForceRechargeAmount
|
||
response.Message = "首次购买需满足最低充值要求"
|
||
}
|
||
}
|
||
|
||
return response, nil
|
||
}
|