Files
junhong_cmp_fiber/internal/service/order/service.go
huang 353621d923
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>
2026-02-11 17:13:42 +08:00

1080 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(&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, "查询订单明细失败")
}
// 任务 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
}