package service import ( "encoding/json" "errors" "fmt" "math" "strings" "time" "unicode/utf8" "affiliate_dash/internal/model" "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" ) type FulfillmentService struct { db *gorm.DB callbacks *CallbackService } func NewFulfillmentService(db *gorm.DB, callbacks *CallbackService) *FulfillmentService { return &FulfillmentService{db: db, callbacks: callbacks} } type CreateFulfillmentOrderInput struct { MerchantID uint APIClientID uint ClientOrderNo string SKU string Quantity int64 BuyerReference string RequestData interface{} } type CreateFulfillmentOrderResult struct { Order *model.FulfillmentOrder `json:"order"` Idempotent bool `json:"idempotent"` } type CreateTestOrderInput struct { MerchantID uint ActorUserID uint SKU string BuyerReference string Note string FulfillmentStatus string } func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*CreateFulfillmentOrderResult, error) { in.ClientOrderNo = strings.TrimSpace(in.ClientOrderNo) in.SKU = strings.TrimSpace(in.SKU) if in.MerchantID == 0 || in.APIClientID == 0 { return nil, errors.New("无效的商户或 API 客户端") } if in.ClientOrderNo == "" || len(in.ClientOrderNo) > 96 { return nil, errors.New("client_order_no 不能为空且最长 96 位") } if in.SKU == "" { return nil, errors.New("sku 不能为空") } if in.Quantity == 0 { in.Quantity = 1 } if in.Quantity < 1 { return nil, errors.New("quantity 必须大于零") } requestData := "" if in.RequestData != nil { raw, err := json.Marshal(in.RequestData) if err != nil { return nil, errors.New("订单请求数据无法序列化") } requestData = string(raw) } result := &CreateFulfillmentOrderResult{} err := s.db.Transaction(func(tx *gorm.DB) error { var existing model.FulfillmentOrder err := tx.Where("merchant_id = ? AND client_order_no = ?", in.MerchantID, in.ClientOrderNo).First(&existing).Error if err == nil { result.Order = &existing result.Idempotent = true return nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return err } var merchant model.Merchant if err := tx.Where("id = ? AND status = ?", in.MerchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("商户不存在或已禁用") } return err } var product model.MerchantProduct if err := tx.Preload("Product").Clauses(clause.Locking{Strength: "UPDATE"}). Where("merchant_id = ? AND sku = ? AND status = ?", in.MerchantID, in.SKU, model.ProductStatusActive). First(&product).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("商品不存在或已下架") } return err } if product.Product == nil || product.Product.Status != model.ProductStatusActive { return errors.New("商品目录已下架") } if product.Stock >= 0 && product.Stock < in.Quantity { return errors.New("商品库存不足") } if product.PriceAmount > 0 && in.Quantity > math.MaxInt64/product.PriceAmount { return errors.New("订单金额超出范围") } baseAmount := product.PriceAmount * in.Quantity serviceFee, err := calculateServiceFee(baseAmount, merchant.FeeType, merchant.FeeRateBP, merchant.FeeFixedAmount) if err != nil { return err } if serviceFee > math.MaxInt64-baseAmount { return errors.New("订单金额超出范围") } totalAmount := baseAmount + serviceFee var wallet model.WalletAccount if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("merchant_id = ?", in.MerchantID).First(&wallet).Error; err != nil { return err } if wallet.AvailableBalance < totalAmount { return errors.New("商户钱包余额不足") } newBalance := wallet.AvailableBalance - totalAmount if err := tx.Model(&wallet).Update("available_balance", newBalance).Error; err != nil { return err } order := &model.FulfillmentOrder{ MerchantID: in.MerchantID, OrderNo: newFulfillmentOrderNo(), ClientOrderNo: in.ClientOrderNo, MerchantProductID: product.ID, ProductSKU: product.SKU, ProductName: fallbackName(product.DisplayName, product.Product.Name), Quantity: in.Quantity, BaseAmount: baseAmount, FeeType: merchant.FeeType, FeeRateBP: merchant.FeeRateBP, FeeFixedAmount: merchant.FeeFixedAmount, ServiceFeeAmount: serviceFee, Amount: totalAmount, Currency: product.Currency, PaymentStatus: model.PaymentStatusPaid, FulfillmentStatus: model.FulfillmentStatusPending, BuyerReference: in.BuyerReference, RequestData: requestData, } if err := tx.Create(order).Error; err != nil { return err } idempotencyKey := in.ClientOrderNo if err := tx.Create(&model.WalletLedgerEntry{ MerchantID: in.MerchantID, WalletAccountID: wallet.ID, EntryNo: "WL" + uuid.NewString(), Type: model.WalletLedgerDebit, Amount: -totalAmount, BalanceAfter: newBalance, ReferenceType: "fulfillment_order", ReferenceNo: order.OrderNo, IdempotencyKey: &idempotencyKey, Note: "开放接口下单扣款(含平台手续费)", }).Error; err != nil { return err } if product.Stock >= 0 { if err := tx.Model(&model.MerchantProduct{}).Where("id = ? AND stock >= ?", product.ID, in.Quantity). Update("stock", gorm.Expr("stock - ?", in.Quantity)).Error; err != nil { return err } } if err := writeAudit(tx, &in.MerchantID, nil, &in.APIClientID, "open_order.create", "fulfillment_order", order.OrderNo, map[string]interface{}{"client_order_no": in.ClientOrderNo, "sku": in.SKU}); err != nil { return err } if s.callbacks != nil { if err := s.callbacks.Enqueue(tx, in.MerchantID, "order.created", orderCallbackData(order)); err != nil { return err } } result.Order = order return nil }) if err != nil { // 并发请求恰好同时通过首次查询时,唯一约束冲突后返回既有订单。 if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "UNIQUE") { var existing model.FulfillmentOrder if queryErr := s.db.Where("merchant_id = ? AND client_order_no = ?", in.MerchantID, in.ClientOrderNo).First(&existing).Error; queryErr == nil { return &CreateFulfillmentOrderResult{Order: &existing, Idempotent: true}, nil } } return nil, err } return result, nil } // CreateTestOrder 创建后台联调订单:不扣钱包、不占库存,只用于上游按订单号查询与发货回传。 func (s *FulfillmentService) CreateTestOrder(in CreateTestOrderInput) (*model.FulfillmentOrder, error) { in.SKU = strings.TrimSpace(in.SKU) in.BuyerReference = strings.TrimSpace(in.BuyerReference) in.Note = strings.TrimSpace(in.Note) if in.MerchantID == 0 { return nil, errors.New("无效的商户") } if in.SKU == "" { return nil, errors.New("sku 不能为空") } if in.BuyerReference == "" { in.BuyerReference = "测试买家" } if len(in.BuyerReference) > 128 { return nil, errors.New("买家标识最长 128 位") } if len(in.Note) > 512 { return nil, errors.New("备注最长 512 位") } if in.FulfillmentStatus == "" { in.FulfillmentStatus = model.FulfillmentStatusPending } switch in.FulfillmentStatus { case model.FulfillmentStatusPending, model.FulfillmentStatusFailed: default: return nil, errors.New("测试订单仅支持待履约或发货失败状态") } requestData := map[string]interface{}{ "source": "merchant_test_order", } if in.Note != "" { requestData["note"] = in.Note } rawRequestData, err := json.Marshal(requestData) if err != nil { return nil, errors.New("订单请求数据无法序列化") } var out model.FulfillmentOrder err = s.db.Transaction(func(tx *gorm.DB) error { var merchant model.Merchant if err := tx.Where("id = ? AND status = ?", in.MerchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("商户不存在或已禁用") } return err } var product model.MerchantProduct if err := tx.Preload("Product"). Where("merchant_id = ? AND sku = ? AND status = ?", in.MerchantID, in.SKU, model.ProductStatusActive). First(&product).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("商品不存在或已下架") } return err } if product.Product == nil || product.Product.Status != model.ProductStatusActive { return errors.New("商品目录已下架") } orderNo := newTestFulfillmentOrderNo() order := &model.FulfillmentOrder{ MerchantID: in.MerchantID, OrderNo: orderNo, ClientOrderNo: "TEST-" + orderNo, MerchantProductID: product.ID, ProductSKU: product.SKU, ProductName: fallbackName(product.DisplayName, product.Product.Name), Quantity: 1, BaseAmount: 0, FeeType: merchant.FeeType, FeeRateBP: merchant.FeeRateBP, FeeFixedAmount: merchant.FeeFixedAmount, ServiceFeeAmount: 0, Amount: 0, Currency: product.Currency, PaymentStatus: model.PaymentStatusPaid, FulfillmentStatus: in.FulfillmentStatus, BuyerReference: in.BuyerReference, RequestData: string(rawRequestData), } if in.FulfillmentStatus == model.FulfillmentStatusFailed { order.FailureReason = fallbackName(in.Note, "联调测试订单初始化为发货失败,可重新发货") } if err := tx.Create(order).Error; err != nil { return err } if err := writeAudit(tx, &in.MerchantID, &in.ActorUserID, nil, "merchant_test_order.create", "fulfillment_order", order.OrderNo, map[string]interface{}{ "sku": in.SKU, "fulfillment_status": in.FulfillmentStatus, "can_ship": true, }); err != nil { return err } out = *order out.MerchantProduct = &product return nil }) if err != nil { return nil, err } return &out, nil } func (s *FulfillmentService) GetOrder(merchantID uint, orderNo string) (*model.FulfillmentOrder, error) { var order model.FulfillmentOrder err := s.db.Preload("MerchantProduct.Product"). Where("merchant_id = ? AND order_no = ?", merchantID, orderNo). First(&order).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("订单不存在") } if err != nil { return nil, err } return &order, nil } func (s *FulfillmentService) ListOrders(merchantID uint, page, size int, fulfillmentStatus string) ([]model.FulfillmentOrder, int64, error) { page, size = normalizePage(page, size) tx := s.db.Model(&model.FulfillmentOrder{}).Where("merchant_id = ?", merchantID) if fulfillmentStatus != "" { tx = tx.Where("fulfillment_status = ?", fulfillmentStatus) } var total int64 if err := tx.Count(&total).Error; err != nil { return nil, 0, err } var orders []model.FulfillmentOrder err := tx.Preload("MerchantProduct.Product").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&orders).Error return orders, total, err } type FulfillmentUpdateInput struct { MerchantID uint APIClientID uint OrderNo string Status string ProviderOrderNo string FailureReason string ResultData interface{} } func (s *FulfillmentService) UpdateFulfillment(in FulfillmentUpdateInput) (*model.FulfillmentOrder, error) { switch in.Status { case model.FulfillmentStatusProcessing, model.FulfillmentStatusSucceeded, model.FulfillmentStatusFailed: default: return nil, errors.New("无效的履约状态") } resultData := "" if in.ResultData != nil { raw, err := json.Marshal(in.ResultData) if err != nil { return nil, errors.New("履约结果无法序列化") } resultData = string(raw) } var out model.FulfillmentOrder err := s.db.Transaction(func(tx *gorm.DB) error { var order model.FulfillmentOrder if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("merchant_id = ? AND order_no = ?", in.MerchantID, in.OrderNo). First(&order).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("订单不存在") } return err } if order.FulfillmentStatus == model.FulfillmentStatusCancelled { return errors.New("订单已取消,不能更新履约状态") } if order.PaymentStatus != model.PaymentStatusPaid { return errors.New("订单未支付,不能履约") } if order.FulfillmentStatus == model.FulfillmentStatusSucceeded && in.Status == model.FulfillmentStatusSucceeded { out = order return nil } if order.FulfillmentStatus == model.FulfillmentStatusSucceeded { return errors.New("订单已履约成功,不能回退状态") } now := time.Now() updates := map[string]interface{}{ "fulfillment_status": in.Status, "result_data": resultData, } if in.ProviderOrderNo != "" { updates["provider_order_no"] = in.ProviderOrderNo } switch in.Status { case model.FulfillmentStatusSucceeded: updates["delivered_at"] = now updates["failure_reason"] = "" case model.FulfillmentStatusFailed: updates["failure_reason"] = in.FailureReason } if err := tx.Model(&order).Updates(updates).Error; err != nil { return err } if err := tx.First(&out, order.ID).Error; err != nil { return err } if err := writeAudit(tx, &in.MerchantID, nil, &in.APIClientID, "fulfillment.update", "fulfillment_order", order.OrderNo, map[string]string{"status": in.Status}); err != nil { return err } if s.callbacks != nil { if err := s.callbacks.Enqueue(tx, in.MerchantID, "order.fulfillment.updated", orderCallbackData(&out)); err != nil { return err } } return nil }) if err != nil { return nil, err } return &out, nil } func (s *FulfillmentService) CancelOrder(merchantID, apiClientID uint, orderNo, reason string) (*model.FulfillmentOrder, error) { var out model.FulfillmentOrder err := s.db.Transaction(func(tx *gorm.DB) error { var order model.FulfillmentOrder if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("merchant_id = ? AND order_no = ?", merchantID, orderNo). First(&order).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("订单不存在") } return err } if order.FulfillmentStatus == model.FulfillmentStatusCancelled { out = order return nil } if order.FulfillmentStatus == model.FulfillmentStatusProcessing || order.FulfillmentStatus == model.FulfillmentStatusSucceeded { return errors.New("订单已进入履约流程,不能取消") } now := time.Now() updates := map[string]interface{}{ "payment_status": model.PaymentStatusRefunded, "fulfillment_status": model.FulfillmentStatusCancelled, "failure_reason": reason, "cancelled_at": now, } if err := tx.Model(&order).Updates(updates).Error; err != nil { return err } var wallet model.WalletAccount if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil { return err } newBalance := wallet.AvailableBalance + order.Amount if err := tx.Model(&wallet).Update("available_balance", newBalance).Error; err != nil { return err } idempotencyKey := "cancel:" + order.OrderNo if err := tx.Create(&model.WalletLedgerEntry{ MerchantID: merchantID, WalletAccountID: wallet.ID, EntryNo: "WL" + uuid.NewString(), Type: model.WalletLedgerRefund, Amount: order.Amount, BalanceAfter: newBalance, ReferenceType: "fulfillment_order", ReferenceNo: order.OrderNo, IdempotencyKey: &idempotencyKey, Note: "订单取消退款", }).Error; err != nil { return err } var product model.MerchantProduct if err := tx.Where("id = ?", order.MerchantProductID).First(&product).Error; err != nil { return err } if product.Stock >= 0 { if err := tx.Model(&product).Update("stock", gorm.Expr("stock + ?", order.Quantity)).Error; err != nil { return err } } if err := tx.First(&out, order.ID).Error; err != nil { return err } if err := writeAudit(tx, &merchantID, nil, &apiClientID, "open_order.cancel", "fulfillment_order", order.OrderNo, nil); err != nil { return err } if s.callbacks != nil { if err := s.callbacks.Enqueue(tx, merchantID, "order.cancelled", orderCallbackData(&out)); err != nil { return err } } return nil }) if err != nil { return nil, err } return &out, nil } type WalletAdjustInput struct { MerchantID uint ActorUserID uint Amount int64 IdempotencyKey string Note string } func (s *FulfillmentService) AdjustWallet(in WalletAdjustInput) (*model.WalletAccount, error) { if in.Amount == 0 { return nil, errors.New("调整金额不能为零") } if in.IdempotencyKey == "" { return nil, errors.New("账务调整必须提供幂等键") } var out model.WalletAccount err := s.db.Transaction(func(tx *gorm.DB) error { var existing model.WalletLedgerEntry if err := tx.Where("merchant_id = ? AND idempotency_key = ?", in.MerchantID, in.IdempotencyKey).First(&existing).Error; err == nil { if err := tx.First(&out, existing.WalletAccountID).Error; err != nil { return err } return nil } else if !errors.Is(err, gorm.ErrRecordNotFound) { return err } var wallet model.WalletAccount if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("merchant_id = ?", in.MerchantID).First(&wallet).Error; err != nil { return err } newBalance := wallet.AvailableBalance + in.Amount if newBalance < 0 { return errors.New("调整后余额不能小于零") } if err := tx.Model(&wallet).Update("available_balance", newBalance).Error; err != nil { return err } entryType := model.WalletLedgerAdjust if in.Amount > 0 { entryType = model.WalletLedgerCredit } else { entryType = model.WalletLedgerDebit } idempotencyKey := in.IdempotencyKey if err := tx.Create(&model.WalletLedgerEntry{ MerchantID: in.MerchantID, WalletAccountID: wallet.ID, EntryNo: "WL" + uuid.NewString(), Type: entryType, Amount: in.Amount, BalanceAfter: newBalance, ReferenceType: "manual_adjustment", ReferenceNo: in.IdempotencyKey, IdempotencyKey: &idempotencyKey, Note: in.Note, }).Error; err != nil { return err } out = wallet out.AvailableBalance = newBalance return writeAudit(tx, &in.MerchantID, &in.ActorUserID, nil, "wallet.adjust", "wallet_account", fmt.Sprint(wallet.ID), map[string]int64{"amount": in.Amount}) }) if err != nil { return nil, err } return &out, nil } func (s *FulfillmentService) GetWallet(merchantID uint) (*model.WalletAccount, error) { var wallet model.WalletAccount if err := s.db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("商户钱包不存在") } return nil, err } return &wallet, nil } func (s *FulfillmentService) ListWalletLedger(merchantID uint, page, size int) ([]model.WalletLedgerEntry, int64, error) { page, size = normalizePage(page, size) tx := s.db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID) var total int64 if err := tx.Count(&total).Error; err != nil { return nil, 0, err } var entries []model.WalletLedgerEntry err := tx.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&entries).Error return entries, total, err } func newFulfillmentOrderNo() string { return "FO" + time.Now().UTC().Format("20060102150405") + strings.ReplaceAll(uuid.NewString()[:12], "-", "") } func newTestFulfillmentOrderNo() string { return "O" + time.Now().UTC().Format("20060102150405") + strings.ReplaceAll(uuid.NewString()[:12], "-", "") } // calculateServiceFee 按"百分比或固定"二选一计算手续费: // - feeType=rate:按 baseAmount * feeRateBP / 10000 计算 // - feeType=fixed:直接取 feeFixedAmount // // 二者互斥,不会叠加。 func calculateServiceFee(baseAmount int64, feeType string, feeRateBP, feeFixedAmount int64) (int64, error) { if baseAmount < 0 || feeRateBP < 0 || feeFixedAmount < 0 { return 0, errors.New("订单金额或手续费配置无效") } switch feeType { case model.FeeTypeFixed: return feeFixedAmount, nil case model.FeeTypeRate, "": if feeRateBP > 10000 { return 0, errors.New("手续费比例不能超过 10000 BP") } if feeRateBP > 0 && baseAmount > math.MaxInt64/feeRateBP { return 0, errors.New("手续费金额超出范围") } return baseAmount * feeRateBP / 10000, nil default: return 0, errors.New("无效的手续费类型") } } func CanFulfill(order *model.FulfillmentOrder) (bool, string) { if order.PaymentStatus != model.PaymentStatusPaid { return false, "订单未支付或已退款" } switch order.FulfillmentStatus { case model.FulfillmentStatusPending, model.FulfillmentStatusFailed: return true, "" case model.FulfillmentStatusProcessing: return false, "订单履约中" case model.FulfillmentStatusSucceeded: return false, "订单已履约成功" case model.FulfillmentStatusCancelled: return false, "订单已取消" default: return false, "订单状态不可履约" } } func orderCallbackData(order *model.FulfillmentOrder) map[string]interface{} { canFulfill, cannotFulfillReason := CanFulfill(order) return map[string]interface{}{ "order_no": order.OrderNo, "client_order_no": order.ClientOrderNo, "product_sku": order.ProductSKU, "quantity": order.Quantity, "base_amount": order.BaseAmount, "fee_type": order.FeeType, "service_fee_amount": order.ServiceFeeAmount, "amount": order.Amount, "currency": order.Currency, "payment_status": order.PaymentStatus, "fulfillment_status": order.FulfillmentStatus, "can_fulfill": canFulfill, "cannot_fulfill_reason": cannotFulfillReason, "provider_order_no": order.ProviderOrderNo, "failure_reason": order.FailureReason, } } // ----- 仪表盘统计 ----- // DashboardStats 仪表盘聚合指标。 type DashboardStats struct { Scope string `json:"scope"` CatalogProductCount int64 `json:"catalog_product_count"` ProductCount int64 `json:"product_count"` ActiveProductCount int64 `json:"active_product_count"` MerchantCount int64 `json:"merchant_count"` ActiveMerchantCount int64 `json:"active_merchant_count"` UserCount int64 `json:"user_count"` OrderCount int64 `json:"order_count"` TodayOrderCount int64 `json:"today_order_count"` TotalSales int64 `json:"total_sales"` TodaySales int64 `json:"today_sales"` TotalFees int64 `json:"total_fees"` TodayFees int64 `json:"today_fees"` PendingOrderCount int64 `json:"pending_order_count"` ProcessingOrderCount int64 `json:"processing_order_count"` SucceededOrderCount int64 `json:"succeeded_order_count"` FailedOrderCount int64 `json:"failed_order_count"` CancelledOrderCount int64 `json:"cancelled_order_count"` WalletAvailableBalance int64 `json:"wallet_available_balance"` WalletFrozenBalance int64 `json:"wallet_frozen_balance"` APIClientCount int64 `json:"api_client_count"` ActiveAPIClientCount int64 `json:"active_api_client_count"` CallbackSubscriptionCount int64 `json:"callback_subscription_count"` PendingCallbackCount int64 `json:"pending_callback_count"` FailedCallbackCount int64 `json:"failed_callback_count"` } type dashboardStatusCount struct { Status string Count int64 } // Dashboard 按角色汇总运营指标:平台管理员看全平台,商户账号看当前商户。 func (s *FulfillmentService) Dashboard(merchantID uint, isPlatformAdmin bool) (*DashboardStats, error) { stats := &DashboardStats{Scope: "merchant"} if isPlatformAdmin { stats.Scope = "platform" } now := time.Now() year, month, day := now.Date() todayStart := time.Date(year, month, day, 0, 0, 0, 0, now.Location()) productScope := func() *gorm.DB { tx := s.db.Model(&model.MerchantProduct{}) if !isPlatformAdmin { tx = tx.Where("merchant_id = ?", merchantID) } return tx } orderScope := func() *gorm.DB { tx := s.db.Model(&model.FulfillmentOrder{}) if !isPlatformAdmin { tx = tx.Where("merchant_id = ?", merchantID) } return tx } walletScope := func() *gorm.DB { tx := s.db.Model(&model.WalletAccount{}) if !isPlatformAdmin { tx = tx.Where("merchant_id = ?", merchantID) } return tx } apiClientScope := func() *gorm.DB { tx := s.db.Model(&model.APIClient{}) if !isPlatformAdmin { tx = tx.Where("merchant_id = ?", merchantID) } return tx } callbackScope := func() *gorm.DB { tx := s.db.Model(&model.CallbackSubscription{}) if !isPlatformAdmin { tx = tx.Where("merchant_id = ?", merchantID) } return tx } callbackDeliveryScope := func() *gorm.DB { tx := s.db.Model(&model.CallbackDelivery{}) if !isPlatformAdmin { tx = tx.Where("merchant_id = ?", merchantID) } return tx } if err := s.db.Model(&model.Product{}).Count(&stats.CatalogProductCount).Error; err != nil { return nil, err } if err := productScope().Count(&stats.ProductCount).Error; err != nil { return nil, err } if err := productScope().Where("status = ?", model.ProductStatusActive).Count(&stats.ActiveProductCount).Error; err != nil { return nil, err } if isPlatformAdmin { if err := s.db.Model(&model.Merchant{}).Count(&stats.MerchantCount).Error; err != nil { return nil, err } if err := s.db.Model(&model.Merchant{}).Where("status = ?", model.MerchantStatusActive).Count(&stats.ActiveMerchantCount).Error; err != nil { return nil, err } if err := s.db.Model(&model.User{}).Count(&stats.UserCount).Error; err != nil { return nil, err } } else { if err := s.db.Model(&model.Merchant{}).Where("id = ?", merchantID).Count(&stats.MerchantCount).Error; err != nil { return nil, err } if err := s.db.Model(&model.Merchant{}).Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).Count(&stats.ActiveMerchantCount).Error; err != nil { return nil, err } if err := s.db.Model(&model.User{}). Joins("JOIN merchant_members ON merchant_members.user_id = users.id"). Where("merchant_members.merchant_id = ?", merchantID). Count(&stats.UserCount).Error; err != nil { return nil, err } stats.CatalogProductCount = stats.ProductCount } if err := orderScope().Count(&stats.OrderCount).Error; err != nil { return nil, err } if err := orderScope().Where("created_at >= ?", todayStart).Count(&stats.TodayOrderCount).Error; err != nil { return nil, err } if err := orderScope().Where("payment_status = ?", model.PaymentStatusPaid). Select("COALESCE(SUM(amount),0)").Scan(&stats.TotalSales).Error; err != nil { return nil, err } if err := orderScope().Where("payment_status = ?", model.PaymentStatusPaid). Where("created_at >= ?", todayStart). Select("COALESCE(SUM(amount),0)").Scan(&stats.TodaySales).Error; err != nil { return nil, err } if err := orderScope().Where("payment_status = ?", model.PaymentStatusPaid). Select("COALESCE(SUM(service_fee_amount),0)").Scan(&stats.TotalFees).Error; err != nil { return nil, err } if err := orderScope().Where("payment_status = ?", model.PaymentStatusPaid). Where("created_at >= ?", todayStart). Select("COALESCE(SUM(service_fee_amount),0)").Scan(&stats.TodayFees).Error; err != nil { return nil, err } var orderStatusCounts []dashboardStatusCount if err := orderScope().Select("fulfillment_status AS status, COUNT(*) AS count"). Group("fulfillment_status").Scan(&orderStatusCounts).Error; err != nil { return nil, err } for _, item := range orderStatusCounts { switch item.Status { case model.FulfillmentStatusPending: stats.PendingOrderCount = item.Count case model.FulfillmentStatusProcessing: stats.ProcessingOrderCount = item.Count case model.FulfillmentStatusSucceeded: stats.SucceededOrderCount = item.Count case model.FulfillmentStatusFailed: stats.FailedOrderCount = item.Count case model.FulfillmentStatusCancelled: stats.CancelledOrderCount = item.Count } } if err := walletScope().Select("COALESCE(SUM(available_balance),0)").Scan(&stats.WalletAvailableBalance).Error; err != nil { return nil, err } if err := walletScope().Select("COALESCE(SUM(frozen_balance),0)").Scan(&stats.WalletFrozenBalance).Error; err != nil { return nil, err } if err := apiClientScope().Count(&stats.APIClientCount).Error; err != nil { return nil, err } if err := apiClientScope().Where("status = ?", model.APIClientStatusActive).Count(&stats.ActiveAPIClientCount).Error; err != nil { return nil, err } if err := callbackScope().Count(&stats.CallbackSubscriptionCount).Error; err != nil { return nil, err } if err := callbackDeliveryScope().Where("status = ?", model.CallbackDeliveryPending).Count(&stats.PendingCallbackCount).Error; err != nil { return nil, err } if err := callbackDeliveryScope().Where("status = ?", model.CallbackDeliveryFailed).Count(&stats.FailedCallbackCount).Error; err != nil { return nil, err } return stats, nil } // ----- 上游 SourceOpen 接口(基于 FulfillmentOrder)----- // OpenOrderQuery 开放接口订单查询结果。 type OpenOrderQuery struct { OrderNo string `json:"order_no"` Status string `json:"status"` CanShip bool `json:"can_ship"` CannotShipReason string `json:"cannot_ship_reason,omitempty"` Product *OpenOrderProduct `json:"product,omitempty"` BuyerName string `json:"buyer_name"` Amount int64 `json:"amount"` ProviderOrderNo string `json:"provider_order_no,omitempty"` CreatedAt time.Time `json:"created_at"` ShippedAt *time.Time `json:"shipped_at"` ShipFailReason string `json:"ship_fail_reason,omitempty"` GameChannel string `json:"game_channel,omitempty"` GameUID string `json:"game_uid,omitempty"` RoleName string `json:"role_name,omitempty"` PayScore int `json:"pay_score,omitempty"` } // OpenOrderProduct 开放接口返回的商品快照。 type OpenOrderProduct struct { Name string `json:"name"` SKU string `json:"sku"` Game string `json:"game"` } // ShipNotifyInput 上游发货结果推送。 type ShipNotifyInput struct { OrderNo string ShipStatus string // success / failed ProviderOrderNo string ShippedAt *time.Time FailReason string RawPayload string GameChannel *string GameUID *string RoleName *string PayScore *int } // ShipNotifyResult 上游推送处理结果。 type ShipNotifyResult struct { OrderNo string `json:"order_no"` Status string `json:"status"` Message string `json:"message"` } // GetByOrderNo 按订单号查询(不限定商户,供上游 SourceOpen 使用)。 func (s *FulfillmentService) GetByOrderNo(orderNo string) (*model.FulfillmentOrder, error) { var order model.FulfillmentOrder err := s.db.Preload("MerchantProduct.Product").Where("order_no = ?", orderNo).First(&order).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("订单不存在") } if err != nil { return nil, err } return &order, nil } // QueryOpenOrder 供上游查询:商品信息 + 是否可发货。 func (s *FulfillmentService) QueryOpenOrder(orderNo string) (*OpenOrderQuery, error) { if orderNo == "" { return nil, errors.New("订单号不能为空") } order, err := s.GetByOrderNo(orderNo) if err != nil { return nil, err } canShip, reason := CanFulfill(order) out := &OpenOrderQuery{ OrderNo: order.OrderNo, Status: fulfillmentStatusToLegacyStatus(order.FulfillmentStatus), CanShip: canShip, CannotShipReason: reason, BuyerName: order.BuyerReference, Amount: order.Amount, ProviderOrderNo: order.ProviderOrderNo, CreatedAt: order.CreatedAt, ShippedAt: order.DeliveredAt, ShipFailReason: order.FailureReason, } if order.MerchantProduct != nil { game := "" if order.MerchantProduct.Product != nil { game = order.MerchantProduct.Product.Category } out.Product = &OpenOrderProduct{ Name: order.ProductName, SKU: order.ProductSKU, Game: game, } } // 新模型无独立的游戏字段列,从 RequestData / ResultData JSON 中还原。 extractGameFields(order.RequestData, out) extractGameFields(order.ResultData, out) return out, nil } // HandleShipNotify 处理上游发货结果推送(幂等),基于 FulfillmentOrder。 func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyResult, error) { in.ShipStatus = strings.TrimSpace(in.ShipStatus) in.FailReason = strings.TrimSpace(in.FailReason) if in.OrderNo == "" { return nil, errors.New("订单号不能为空") } if in.ShipStatus != "success" && in.ShipStatus != "failed" { return nil, errors.New("无效的 ship_status,仅支持 success/failed") } if in.ShipStatus == "failed" && in.FailReason == "" { return nil, errors.New("发货失败时 fail_reason 必填") } if utf8.RuneCountInString(in.FailReason) > 512 { return nil, errors.New("fail_reason 最长 512 个字符") } var nextStatus string switch in.ShipStatus { case "success": nextStatus = model.FulfillmentStatusSucceeded case "failed": nextStatus = model.FulfillmentStatusFailed } var result ShipNotifyResult var rejectionErr error if err := s.db.Transaction(func(tx *gorm.DB) error { var order model.FulfillmentOrder if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Preload("MerchantProduct.Product"). Where("order_no = ?", in.OrderNo). First(&order).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("订单不存在") } return err } // 已履约成功:success 推送幂等成功。状态读取和后续更新必须在同一把行锁内完成。 if order.FulfillmentStatus == model.FulfillmentStatusSucceeded && in.ShipStatus == "success" { result = ShipNotifyResult{ OrderNo: order.OrderNo, Status: fulfillmentStatusToLegacyStatus(order.FulfillmentStatus), Message: "订单已交付,幂等成功", } return writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo, shipNotifyAuditMetadata(in, order.FulfillmentStatus, "订单已履约成功,幂等忽略")) } if order.FulfillmentStatus == model.FulfillmentStatusCancelled { if err := writeShipNotifyRejectedAudit(tx, &order, in, "订单已取消,拒绝更新"); err != nil { return err } rejectionErr = errors.New("订单已取消,无法更新发货状态") return nil } now := time.Now() shippedAt := in.ShippedAt if shippedAt == nil && in.ShipStatus == "success" { shippedAt = &now } updates := map[string]interface{}{ "fulfillment_status": nextStatus, } var message string switch in.ShipStatus { case "success": if order.FulfillmentStatus != model.FulfillmentStatusPending && order.FulfillmentStatus != model.FulfillmentStatusFailed && order.FulfillmentStatus != model.FulfillmentStatusProcessing { if err := writeShipNotifyRejectedAudit(tx, &order, in, "当前状态不允许标记发货成功"); err != nil { return err } rejectionErr = fmt.Errorf("当前状态 %s 不允许标记发货成功", order.FulfillmentStatus) return nil } updates["delivered_at"] = shippedAt updates["failure_reason"] = "" if in.ProviderOrderNo != "" { updates["provider_order_no"] = in.ProviderOrderNo } message = "发货成功,订单已交付" case "failed": if order.FulfillmentStatus == model.FulfillmentStatusSucceeded { if err := writeShipNotifyRejectedAudit(tx, &order, in, "订单已交付,拒绝失败推送"); err != nil { return err } rejectionErr = errors.New("订单已交付,不能标记发货失败") return nil } updates["failure_reason"] = in.FailReason if in.ProviderOrderNo != "" { updates["provider_order_no"] = in.ProviderOrderNo } message = "已记录发货失败" } resultData := buildShipNotifyResultData(order.ResultData, in, shippedAt) updates["result_data"] = resultData if err := tx.Model(&model.FulfillmentOrder{}).Where("id = ?", order.ID).Updates(updates).Error; err != nil { return err } if err := writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo, shipNotifyAuditMetadata(in, nextStatus, message)); err != nil { return err } var updated model.FulfillmentOrder if err := tx.Preload("MerchantProduct.Product").First(&updated, order.ID).Error; err != nil { return err } if s.callbacks != nil { if err := s.callbacks.Enqueue(tx, order.MerchantID, "order.fulfillment.updated", orderCallbackData(&updated)); err != nil { return err } } result = ShipNotifyResult{ OrderNo: order.OrderNo, Status: fulfillmentStatusToLegacyStatus(nextStatus), Message: message, } return nil }); err != nil { return nil, err } if rejectionErr != nil { return nil, rejectionErr } return &result, nil } func writeShipNotifyRejectedAudit(tx *gorm.DB, order *model.FulfillmentOrder, in ShipNotifyInput, message string) error { return writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo, shipNotifyAuditMetadata(in, order.FulfillmentStatus, message)) } func shipNotifyAuditMetadata(in ShipNotifyInput, resultStatus, message string) map[string]interface{} { metadata := map[string]interface{}{ "ship_status": in.ShipStatus, "provider_order_no": in.ProviderOrderNo, "fail_reason": in.FailReason, "result_status": resultStatus, "message": message, "payload": in.RawPayload, } return metadata } // buildShipNotifyResultData 把推送结果与游戏字段合并进 ResultData JSON。 func buildShipNotifyResultData(existing string, in ShipNotifyInput, shippedAt *time.Time) string { m := map[string]interface{}{} if existing != "" && json.Valid([]byte(existing)) { _ = json.Unmarshal([]byte(existing), &m) } m["ship_status"] = in.ShipStatus if in.ProviderOrderNo != "" { m["provider_order_no"] = in.ProviderOrderNo } if in.ShipStatus == "success" { delete(m, "fail_reason") } else if in.FailReason != "" { m["fail_reason"] = in.FailReason } if shippedAt != nil { m["shipped_at"] = shippedAt.Format(time.RFC3339) } if in.GameChannel != nil { m["game_channel"] = *in.GameChannel } if in.GameUID != nil { m["game_uid"] = *in.GameUID } if in.RoleName != nil { m["role_name"] = *in.RoleName } if in.PayScore != nil { m["pay_score"] = *in.PayScore } raw, err := json.Marshal(m) if err != nil { return existing } return string(raw) } // extractGameFields 从 JSON 文本中还原游戏相关字段(仅填充当前为空的字段)。 func extractGameFields(raw string, out *OpenOrderQuery) { if raw == "" || !json.Valid([]byte(raw)) { return } var m map[string]interface{} if err := json.Unmarshal([]byte(raw), &m); err != nil { return } if out.GameChannel == "" { if v, ok := m["game_channel"].(string); ok { out.GameChannel = v } } if out.GameUID == "" { if v, ok := m["game_uid"].(string); ok { out.GameUID = v } } if out.RoleName == "" { if v, ok := m["role_name"].(string); ok { out.RoleName = v } } if out.PayScore == 0 { if v, ok := toInt(m["pay_score"]); ok { out.PayScore = v } } } func toInt(v interface{}) (int, bool) { switch n := v.(type) { case float64: return int(n), true case int: return n, true case int64: return int(n), true } return 0, false } // fulfillmentStatusToLegacyStatus 将新的履约状态映射为上游兼容的旧状态字符串。 func fulfillmentStatusToLegacyStatus(status string) string { switch status { case model.FulfillmentStatusPending: return "paid" case model.FulfillmentStatusProcessing: return "delivering" case model.FulfillmentStatusSucceeded: return "delivered" case model.FulfillmentStatusFailed: return "ship_failed" case model.FulfillmentStatusCancelled: return "cancelled" default: return status } }