From 5ed36c724f3f59bf8d6fb02f098eafd809565eb7 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 6 Oct 2026 19:50:13 +0800 Subject: [PATCH] =?UTF-8?q?refactor(fulfillment):=20=E6=8C=89=E8=81=8C?= =?UTF-8?q?=E8=B4=A3=E6=8B=86=E5=88=86=20fulfillment.go=EF=BC=88640=20?= =?UTF-8?q?=E2=86=92=20=E6=9C=80=E5=A4=A7=20235=20=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯文件级移动,无行为变更,沿用既有的 fulfillment_.go 命名约定: - fulfillment.go 结构体、构造函数、上游钩子、CanFulfill、orderCallbackData - fulfillment_create.go 下单类型定义、CreateManualOrder、CreateOrder - fulfillment_testorder.go 联调测试订单 - fulfillment_update.go 履约结果更新 - fulfillment_cancel.go 取消订单(含上游联动取消) 校验:拆分前后 19 个 func/type 声明完全一致(无缺失、无多出、无重复)。 --- backend/internal/service/fulfillment.go | 562 ------------------ .../internal/service/fulfillment_cancel.go | 143 +++++ .../internal/service/fulfillment_create.go | 235 ++++++++ .../internal/service/fulfillment_testorder.go | 131 ++++ .../internal/service/fulfillment_update.go | 91 +++ 5 files changed, 600 insertions(+), 562 deletions(-) create mode 100644 backend/internal/service/fulfillment_cancel.go create mode 100644 backend/internal/service/fulfillment_create.go create mode 100644 backend/internal/service/fulfillment_testorder.go create mode 100644 backend/internal/service/fulfillment_update.go diff --git a/backend/internal/service/fulfillment.go b/backend/internal/service/fulfillment.go index 1a96b25..8500354 100644 --- a/backend/internal/service/fulfillment.go +++ b/backend/internal/service/fulfillment.go @@ -2,18 +2,9 @@ package service import ( "context" - "encoding/json" - "errors" - "math" - "strings" - "time" - "unicode/utf8" "affiliate_dash/internal/model" - - "github.com/google/uuid" "gorm.io/gorm" - "gorm.io/gorm/clause" ) type FulfillmentService struct { @@ -37,559 +28,6 @@ func (s *FulfillmentService) SetUpstreamSync(fn func(ctx context.Context, order s.upstreamSync = fn } -type CreateFulfillmentOrderInput struct { - MerchantID uint - APIClientID uint - ActorUserID uint - ClientOrderNo string - SKU string - Quantity int64 - BuyerReference string - RequestData interface{} - OrderSource string -} - -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 - OrderStatus string -} - -// CreateManualOrderInput 是商户后台创建真实订单的输入。其账务、库存与回调规则与开放 API 下单一致。 -type CreateManualOrderInput struct { - MerchantID uint - ActorUserID uint - ClientOrderNo string - SKU string - Quantity int64 - BuyerReference string - RequestData interface{} -} - -func (s *FulfillmentService) CreateManualOrder(in CreateManualOrderInput) (*CreateFulfillmentOrderResult, error) { - return s.CreateOrder(CreateFulfillmentOrderInput{ - MerchantID: in.MerchantID, - ActorUserID: in.ActorUserID, - ClientOrderNo: in.ClientOrderNo, - SKU: in.SKU, - Quantity: in.Quantity, - BuyerReference: in.BuyerReference, - RequestData: in.RequestData, - OrderSource: model.OrderSourceManual, - }) -} - -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 && in.ActorUserID == 0) { - return nil, errors.New("无效的商户或下单身份") - } - 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 必须大于零") - } - orderSource := strings.TrimSpace(in.OrderSource) - if orderSource == "" { - orderSource = model.OrderSourceAPI - } - switch orderSource { - case model.OrderSourceAPI, model.OrderSourceManual: - default: - return nil, errors.New("无效的订单来源") - } - requestData := "" - if in.RequestData != nil { - raw, err := json.Marshal(in.RequestData) - if err != nil { - return nil, errors.New("订单请求数据无法序列化") - } - requestData = string(raw) - } - fingerprint := orderRequestFingerprint(in, requestData) - - 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 { - if err := ensureSameIdempotentOrder(&existing, fingerprint); err != nil { - return err - } - 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, - OrderStatus: model.OrderStatusPaid, - OrderSource: orderSource, - BuyerReference: in.BuyerReference, - RequestFingerprint: fingerprint, - RequestData: requestData, - } - if err := tx.Create(order).Error; err != nil { - return err - } - idempotencyKey := in.ClientOrderNo - ledgerNote := "开放接口下单扣款(含平台手续费)" - if orderSource == model.OrderSourceManual { - ledgerNote = "商户后台手动下单扣款(含平台手续费)" - } - 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: ledgerNote, - }).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 - } - } - action := "open_order.create" - if orderSource == model.OrderSourceManual { - action = "merchant_order.manual_create" - } - if err := writeAudit(tx, &in.MerchantID, optionalUint(in.ActorUserID), optionalUint(in.APIClientID), action, "fulfillment_order", order.OrderNo, map[string]interface{}{"client_order_no": in.ClientOrderNo, "sku": in.SKU, "source": orderSource}); err != nil { - return err - } - if s.callbacks != nil && orderSource != model.OrderSourceManual { - 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 { - if sameErr := ensureSameIdempotentOrder(&existing, fingerprint); sameErr != nil { - return nil, sameErr - } - 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.OrderStatus == "" { - in.OrderStatus = model.OrderStatusPaid - } - switch in.OrderStatus { - case model.OrderStatusPaid, model.OrderStatusShipFailed, model.OrderStatusCancelled: - 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, - OrderStatus: in.OrderStatus, - OrderSource: model.OrderSourceTest, - BuyerReference: in.BuyerReference, - RequestData: string(rawRequestData), - } - if in.OrderStatus == model.OrderStatusShipFailed { - order.FailureReason = fallbackName(in.Note, "联调测试订单初始化为发货失败,可重新发货") - } - if in.OrderStatus == model.OrderStatusCancelled { - now := time.Now() - order.CancelledAt = &now - } - if err := tx.Create(order).Error; err != nil { - return err - } - canShip, _ := CanFulfill(order) - if err := writeAudit(tx, &in.MerchantID, &in.ActorUserID, nil, "merchant_test_order.create", "fulfillment_order", order.OrderNo, map[string]interface{}{ - "sku": in.SKU, - "order_status": in.OrderStatus, - "can_ship": canShip, - }); err != nil { - return err - } - out = *order - out.MerchantProduct = &product - return nil - }) - if err != nil { - return nil, err - } - return &out, nil -} - -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.OrderStatusDelivering, model.OrderStatusDelivered, model.OrderStatusShipFailed: - default: - return nil, errors.New("无效的订单状态") - } - nextOrderStatus := in.Status - var resultData string - hasResultData := in.ResultData != nil - if in.ResultData != nil { - encoded, err := encodeFulfillmentResultData(in.ResultData) - if err != nil { - return nil, errors.New("发货结果无法序列化") - } - resultData = encoded - } - 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 normalizeOrderStatus(&order) == model.OrderStatusDelivered && nextOrderStatus == model.OrderStatusDelivered { - out = order - return nil - } - if err := validateOrderStatusTransition(&order, nextOrderStatus, fulfillmentTransitionUpdate); err != nil { - return err - } - now := time.Now() - updates := map[string]interface{}{"order_status": nextOrderStatus} - if hasResultData { - updates["result_data"] = resultData - } - if in.ProviderOrderNo != "" { - updates["provider_order_no"] = in.ProviderOrderNo - } - switch nextOrderStatus { - case model.OrderStatusDelivered: - updates["delivered_at"] = now - updates["failure_reason"] = "" - case model.OrderStatusShipFailed: - 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": nextOrderStatus}); err != nil { - return err - } - if s.callbacks != nil { - if err := s.callbacks.Enqueue(tx, in.MerchantID, "order.shipping.updated", orderCallbackData(&out)); err != nil { - return err - } - } - return nil - }) - if err != nil { - return nil, err - } - return &out, nil -} - -// CancelOrderInput 取消订单的公共输入;开放 API 与商户后台共用同一取消事务。 -type CancelOrderInput struct { - MerchantID uint - APIClientID uint - ActorUserID uint - OrderNo string - Reason string - Action string -} - -// CancelOrder 商户开放接口取消订单(审计记录 API 客户端)。 -func (s *FulfillmentService) CancelOrder(merchantID, apiClientID uint, orderNo, reason string) (*model.FulfillmentOrder, error) { - return s.cancelOrder(CancelOrderInput{ - MerchantID: merchantID, - APIClientID: apiClientID, - OrderNo: orderNo, - Reason: reason, - Action: "open_order.cancel", - }) -} - -// CancelOrderByUser 商户后台取消订单(审计记录操作人)。 -func (s *FulfillmentService) CancelOrderByUser(merchantID, actorUserID uint, orderNo, reason string) (*model.FulfillmentOrder, error) { - return s.cancelOrder(CancelOrderInput{ - MerchantID: merchantID, - ActorUserID: actorUserID, - OrderNo: orderNo, - Reason: reason, - Action: "merchant_order.cancel", - }) -} - -func (s *FulfillmentService) cancelOrder(in CancelOrderInput) (*model.FulfillmentOrder, error) { - in.OrderNo = strings.TrimSpace(in.OrderNo) - in.Reason = strings.TrimSpace(in.Reason) - if in.MerchantID == 0 || in.OrderNo == "" { - return nil, errors.New("无效的商户或订单号") - } - if utf8.RuneCountInString(in.Reason) > 512 { - return nil, errors.New("取消原因最长 512 字") - } - 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 normalizeOrderStatus(&order) == model.OrderStatusCancelled { - out = order - return nil - } - if err := canCancelOrder(&order); err != nil { - return err - } - // 已提交上游正式订单的失败订单禁止取消:退款后上游仍可能继续发货,造成双重损失。 - // 与发货重试的防重复逻辑(deliverySubmittedUpstream)保持一致。 - if normalizeOrderStatus(&order) == model.OrderStatusShipFailed && deliverySubmittedUpstream(&order) { - return errors.New("订单已提交上游发货,为避免重复发货无法取消,请先在上游确认订单状态") - } - // 先取消上游履约单,避免本地退款后上游仍占库存/资金 - if s.upstreamCancel != nil { - if err := s.upstreamCancel(&order); err != nil { - return errors.New("取消上游履约订单失败: " + err.Error()) - } - } - now := time.Now() - updates := map[string]interface{}{"order_status": model.OrderStatusCancelled} - updates["failure_reason"] = in.Reason - updates["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 = ?", in.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: in.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, &in.MerchantID, optionalUint(in.ActorUserID), optionalUint(in.APIClientID), in.Action, "fulfillment_order", order.OrderNo, map[string]interface{}{ - "reason": in.Reason, - }); err != nil { - return err - } - if s.callbacks != nil { - if err := s.callbacks.Enqueue(tx, in.MerchantID, "order.cancelled", orderCallbackData(&out)); err != nil { - return err - } - } - return nil - }) - if err != nil { - return nil, err - } - return &out, nil -} - func CanFulfill(order *model.FulfillmentOrder) (bool, string) { switch normalizeOrderStatus(order) { case model.OrderStatusPaid: diff --git a/backend/internal/service/fulfillment_cancel.go b/backend/internal/service/fulfillment_cancel.go new file mode 100644 index 0000000..ccd44b2 --- /dev/null +++ b/backend/internal/service/fulfillment_cancel.go @@ -0,0 +1,143 @@ +package service + +import ( + "errors" + "strings" + "time" + "unicode/utf8" + + "affiliate_dash/internal/model" + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// CancelOrderInput 取消订单的公共输入;开放 API 与商户后台共用同一取消事务。 +type CancelOrderInput struct { + MerchantID uint + APIClientID uint + ActorUserID uint + OrderNo string + Reason string + Action string +} + +// CancelOrder 商户开放接口取消订单(审计记录 API 客户端)。 +func (s *FulfillmentService) CancelOrder(merchantID, apiClientID uint, orderNo, reason string) (*model.FulfillmentOrder, error) { + return s.cancelOrder(CancelOrderInput{ + MerchantID: merchantID, + APIClientID: apiClientID, + OrderNo: orderNo, + Reason: reason, + Action: "open_order.cancel", + }) +} + +// CancelOrderByUser 商户后台取消订单(审计记录操作人)。 +func (s *FulfillmentService) CancelOrderByUser(merchantID, actorUserID uint, orderNo, reason string) (*model.FulfillmentOrder, error) { + return s.cancelOrder(CancelOrderInput{ + MerchantID: merchantID, + ActorUserID: actorUserID, + OrderNo: orderNo, + Reason: reason, + Action: "merchant_order.cancel", + }) +} + +func (s *FulfillmentService) cancelOrder(in CancelOrderInput) (*model.FulfillmentOrder, error) { + in.OrderNo = strings.TrimSpace(in.OrderNo) + in.Reason = strings.TrimSpace(in.Reason) + if in.MerchantID == 0 || in.OrderNo == "" { + return nil, errors.New("无效的商户或订单号") + } + if utf8.RuneCountInString(in.Reason) > 512 { + return nil, errors.New("取消原因最长 512 字") + } + 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 normalizeOrderStatus(&order) == model.OrderStatusCancelled { + out = order + return nil + } + if err := canCancelOrder(&order); err != nil { + return err + } + // 已提交上游正式订单的失败订单禁止取消:退款后上游仍可能继续发货,造成双重损失。 + // 与发货重试的防重复逻辑(deliverySubmittedUpstream)保持一致。 + if normalizeOrderStatus(&order) == model.OrderStatusShipFailed && deliverySubmittedUpstream(&order) { + return errors.New("订单已提交上游发货,为避免重复发货无法取消,请先在上游确认订单状态") + } + // 先取消上游履约单,避免本地退款后上游仍占库存/资金 + if s.upstreamCancel != nil { + if err := s.upstreamCancel(&order); err != nil { + return errors.New("取消上游履约订单失败: " + err.Error()) + } + } + now := time.Now() + updates := map[string]interface{}{"order_status": model.OrderStatusCancelled} + updates["failure_reason"] = in.Reason + updates["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 = ?", in.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: in.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, &in.MerchantID, optionalUint(in.ActorUserID), optionalUint(in.APIClientID), in.Action, "fulfillment_order", order.OrderNo, map[string]interface{}{ + "reason": in.Reason, + }); err != nil { + return err + } + if s.callbacks != nil { + if err := s.callbacks.Enqueue(tx, in.MerchantID, "order.cancelled", orderCallbackData(&out)); err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err + } + return &out, nil +} diff --git a/backend/internal/service/fulfillment_create.go b/backend/internal/service/fulfillment_create.go new file mode 100644 index 0000000..63ec6be --- /dev/null +++ b/backend/internal/service/fulfillment_create.go @@ -0,0 +1,235 @@ +package service + +import ( + "encoding/json" + "errors" + "math" + "strings" + + "affiliate_dash/internal/model" + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type CreateFulfillmentOrderInput struct { + MerchantID uint + APIClientID uint + ActorUserID uint + ClientOrderNo string + SKU string + Quantity int64 + BuyerReference string + RequestData interface{} + OrderSource string +} + +type CreateFulfillmentOrderResult struct { + Order *model.FulfillmentOrder `json:"order"` + Idempotent bool `json:"idempotent"` +} + +// CreateManualOrderInput 是商户后台创建真实订单的输入。其账务、库存与回调规则与开放 API 下单一致。 +type CreateManualOrderInput struct { + MerchantID uint + ActorUserID uint + ClientOrderNo string + SKU string + Quantity int64 + BuyerReference string + RequestData interface{} +} + +func (s *FulfillmentService) CreateManualOrder(in CreateManualOrderInput) (*CreateFulfillmentOrderResult, error) { + return s.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: in.MerchantID, + ActorUserID: in.ActorUserID, + ClientOrderNo: in.ClientOrderNo, + SKU: in.SKU, + Quantity: in.Quantity, + BuyerReference: in.BuyerReference, + RequestData: in.RequestData, + OrderSource: model.OrderSourceManual, + }) +} + +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 && in.ActorUserID == 0) { + return nil, errors.New("无效的商户或下单身份") + } + 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 必须大于零") + } + orderSource := strings.TrimSpace(in.OrderSource) + if orderSource == "" { + orderSource = model.OrderSourceAPI + } + switch orderSource { + case model.OrderSourceAPI, model.OrderSourceManual: + default: + return nil, errors.New("无效的订单来源") + } + requestData := "" + if in.RequestData != nil { + raw, err := json.Marshal(in.RequestData) + if err != nil { + return nil, errors.New("订单请求数据无法序列化") + } + requestData = string(raw) + } + fingerprint := orderRequestFingerprint(in, requestData) + + 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 { + if err := ensureSameIdempotentOrder(&existing, fingerprint); err != nil { + return err + } + 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, + OrderStatus: model.OrderStatusPaid, + OrderSource: orderSource, + BuyerReference: in.BuyerReference, + RequestFingerprint: fingerprint, + RequestData: requestData, + } + if err := tx.Create(order).Error; err != nil { + return err + } + idempotencyKey := in.ClientOrderNo + ledgerNote := "开放接口下单扣款(含平台手续费)" + if orderSource == model.OrderSourceManual { + ledgerNote = "商户后台手动下单扣款(含平台手续费)" + } + 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: ledgerNote, + }).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 + } + } + action := "open_order.create" + if orderSource == model.OrderSourceManual { + action = "merchant_order.manual_create" + } + if err := writeAudit(tx, &in.MerchantID, optionalUint(in.ActorUserID), optionalUint(in.APIClientID), action, "fulfillment_order", order.OrderNo, map[string]interface{}{"client_order_no": in.ClientOrderNo, "sku": in.SKU, "source": orderSource}); err != nil { + return err + } + if s.callbacks != nil && orderSource != model.OrderSourceManual { + 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 { + if sameErr := ensureSameIdempotentOrder(&existing, fingerprint); sameErr != nil { + return nil, sameErr + } + return &CreateFulfillmentOrderResult{Order: &existing, Idempotent: true}, nil + } + } + return nil, err + } + return result, nil +} diff --git a/backend/internal/service/fulfillment_testorder.go b/backend/internal/service/fulfillment_testorder.go new file mode 100644 index 0000000..2a4f939 --- /dev/null +++ b/backend/internal/service/fulfillment_testorder.go @@ -0,0 +1,131 @@ +package service + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "affiliate_dash/internal/model" + "gorm.io/gorm" +) + +type CreateTestOrderInput struct { + MerchantID uint + ActorUserID uint + SKU string + BuyerReference string + Note string + OrderStatus string +} + +// 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.OrderStatus == "" { + in.OrderStatus = model.OrderStatusPaid + } + switch in.OrderStatus { + case model.OrderStatusPaid, model.OrderStatusShipFailed, model.OrderStatusCancelled: + 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, + OrderStatus: in.OrderStatus, + OrderSource: model.OrderSourceTest, + BuyerReference: in.BuyerReference, + RequestData: string(rawRequestData), + } + if in.OrderStatus == model.OrderStatusShipFailed { + order.FailureReason = fallbackName(in.Note, "联调测试订单初始化为发货失败,可重新发货") + } + if in.OrderStatus == model.OrderStatusCancelled { + now := time.Now() + order.CancelledAt = &now + } + if err := tx.Create(order).Error; err != nil { + return err + } + canShip, _ := CanFulfill(order) + if err := writeAudit(tx, &in.MerchantID, &in.ActorUserID, nil, "merchant_test_order.create", "fulfillment_order", order.OrderNo, map[string]interface{}{ + "sku": in.SKU, + "order_status": in.OrderStatus, + "can_ship": canShip, + }); err != nil { + return err + } + out = *order + out.MerchantProduct = &product + return nil + }) + if err != nil { + return nil, err + } + return &out, nil +} diff --git a/backend/internal/service/fulfillment_update.go b/backend/internal/service/fulfillment_update.go new file mode 100644 index 0000000..6b4703c --- /dev/null +++ b/backend/internal/service/fulfillment_update.go @@ -0,0 +1,91 @@ +package service + +import ( + "errors" + "time" + + "affiliate_dash/internal/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +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.OrderStatusDelivering, model.OrderStatusDelivered, model.OrderStatusShipFailed: + default: + return nil, errors.New("无效的订单状态") + } + nextOrderStatus := in.Status + var resultData string + hasResultData := in.ResultData != nil + if in.ResultData != nil { + encoded, err := encodeFulfillmentResultData(in.ResultData) + if err != nil { + return nil, errors.New("发货结果无法序列化") + } + resultData = encoded + } + 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 normalizeOrderStatus(&order) == model.OrderStatusDelivered && nextOrderStatus == model.OrderStatusDelivered { + out = order + return nil + } + if err := validateOrderStatusTransition(&order, nextOrderStatus, fulfillmentTransitionUpdate); err != nil { + return err + } + now := time.Now() + updates := map[string]interface{}{"order_status": nextOrderStatus} + if hasResultData { + updates["result_data"] = resultData + } + if in.ProviderOrderNo != "" { + updates["provider_order_no"] = in.ProviderOrderNo + } + switch nextOrderStatus { + case model.OrderStatusDelivered: + updates["delivered_at"] = now + updates["failure_reason"] = "" + case model.OrderStatusShipFailed: + 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": nextOrderStatus}); err != nil { + return err + } + if s.callbacks != nil { + if err := s.callbacks.Enqueue(tx, in.MerchantID, "order.shipping.updated", orderCallbackData(&out)); err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err + } + return &out, nil +}