纯文件级移动,无行为变更,沿用既有的 fulfillment_<aspect>.go 命名约定: - fulfillment.go 结构体、构造函数、上游钩子、CanFulfill、orderCallbackData - fulfillment_create.go 下单类型定义、CreateManualOrder、CreateOrder - fulfillment_testorder.go 联调测试订单 - fulfillment_update.go 履约结果更新 - fulfillment_cancel.go 取消订单(含上游联动取消) 校验:拆分前后 19 个 func/type 声明完全一致(无缺失、无多出、无重复)。
144 lines
4.7 KiB
Go
144 lines
4.7 KiB
Go
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
|
|
}
|