Files
affiliate_dash/backend/internal/service/fulfillment.go
T

1321 lines
43 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 service
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"math"
"strings"
"time"
"unicode/utf8"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/timeutil"
"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
OrderStatus 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)
}
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,
BuyerReference: in.BuyerReference,
RequestFingerprint: fingerprint,
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 {
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:
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,
BuyerReference: in.BuyerReference,
RequestData: string(rawRequestData),
}
if in.OrderStatus == model.OrderStatusShipFailed {
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,
"order_status": in.OrderStatus,
"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, orderStatus string) ([]model.FulfillmentOrder, int64, error) {
page, size = normalizePage(page, size)
tx := s.db.Model(&model.FulfillmentOrder{}).Where("merchant_id = ?", merchantID)
if orderStatus != "" {
tx = tx.Where("order_status = ?", orderStatus)
}
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.OrderStatusDelivering, model.OrderStatusDelivered, model.OrderStatusShipFailed:
default:
return nil, errors.New("无效的订单状态")
}
nextOrderStatus := in.Status
var resultData string
hasResultData := in.ResultData != nil
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 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.fulfillment.updated", orderCallbackData(&out)); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
return &out, nil
}
func (s *FulfillmentService) MarkProcessingTimeouts(timeout time.Duration, limit int) (int, error) {
if timeout <= 0 {
return 0, nil
}
if limit <= 0 || limit > 100 {
limit = 50
}
now := time.Now()
cutoff := now.Add(-timeout)
var ids []uint
if err := s.db.Model(&model.FulfillmentOrder{}).
Where("order_status = ? AND updated_at < ?", model.OrderStatusDelivering, cutoff).
Order("updated_at ASC, id ASC").
Limit(limit).
Pluck("id", &ids).Error; err != nil {
return 0, err
}
changed := 0
for _, id := range ids {
updated, err := s.markProcessingTimeout(id, timeout, now)
if err != nil {
return changed, err
}
if updated {
changed++
}
}
return changed, nil
}
func (s *FulfillmentService) markProcessingTimeout(id uint, timeout time.Duration, now time.Time) (bool, error) {
returned := false
err := s.db.Transaction(func(tx *gorm.DB) error {
var order model.FulfillmentOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
if !processingTimedOut(&order, timeout, now) {
return nil
}
if err := validateOrderStatusTransition(&order, model.OrderStatusShipFailed, fulfillmentTransitionTimeout); err != nil {
return nil
}
reason := fmt.Sprintf("发货超时:订单已处于 delivering 超过 %d 分钟", int(timeout.Minutes()))
updates := map[string]interface{}{"order_status": model.OrderStatusShipFailed}
updates["failure_reason"] = reason
updates["result_data"] = buildProcessingTimeoutResultData(order.ResultData, timeout, now)
if err := tx.Model(&order).Updates(updates).Error; err != nil {
return err
}
var out model.FulfillmentOrder
if err := tx.First(&out, order.ID).Error; err != nil {
return err
}
if err := writeAudit(tx, &order.MerchantID, nil, nil, "fulfillment.timeout", "fulfillment_order", order.OrderNo, map[string]interface{}{
"from": normalizeOrderStatus(&order),
"to": model.OrderStatusShipFailed,
"timeout_minutes": int(timeout.Minutes()),
"reason": reason,
}); err != nil {
return err
}
if s.callbacks != nil {
if err := s.callbacks.Enqueue(tx, order.MerchantID, "order.fulfillment.updated", orderCallbackData(&out)); err != nil {
return err
}
}
returned = true
return nil
})
return returned, err
}
func (s *FulfillmentService) RunProcessingTimeoutMonitor(ctx context.Context, timeout, interval time.Duration) {
if timeout <= 0 {
return
}
if interval <= 0 {
interval = time.Minute
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
changed, err := s.MarkProcessingTimeouts(timeout, 50)
if err != nil {
log.Printf("[fulfillment] timeout scan error: %v", err)
} else if changed > 0 {
log.Printf("[fulfillment] timeout scan marked failed count=%d", changed)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
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 normalizeOrderStatus(&order) == model.OrderStatusCancelled {
out = order
return nil
}
if err := canCancelOrder(&order); err != nil {
return err
}
now := time.Now()
updates := map[string]interface{}{"order_status": model.OrderStatusCancelled}
updates["failure_reason"] = 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 = ?", 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" + timeutil.Now().Format(timeutil.OrderNoLayout) + strings.ReplaceAll(uuid.NewString()[:12], "-", "")
}
func newTestFulfillmentOrderNo() string {
return "O" + timeutil.Now().Format(timeutil.OrderNoLayout) + 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) {
switch normalizeOrderStatus(order) {
case model.OrderStatusPaid, model.OrderStatusShipFailed:
return true, ""
case model.OrderStatusPending:
return false, "订单未支付"
case model.OrderStatusDelivering:
return false, "订单发货中"
case model.OrderStatusDelivered:
return false, "订单已交付"
case model.OrderStatusCancelled:
return false, "订单已取消"
default:
return false, "订单状态不可发货"
}
}
func orderCallbackData(order *model.FulfillmentOrder) map[string]interface{} {
canShip, cannotShipReason := 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,
"order_status": normalizeOrderStatus(order),
"can_ship": canShip,
"cannot_ship_reason": cannotShipReason,
"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"`
PaidOrderCount int64 `json:"paid_order_count"`
DeliveringOrderCount int64 `json:"delivering_order_count"`
DeliveredOrderCount int64 `json:"delivered_order_count"`
ShipFailedOrderCount int64 `json:"ship_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"
}
todayStart := timeutil.StartOfDay(time.Now())
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("order_status <> ?", model.OrderStatusCancelled).
Select("COALESCE(SUM(amount),0)").Scan(&stats.TotalSales).Error; err != nil {
return nil, err
}
if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled).
Where("created_at >= ?", todayStart).
Select("COALESCE(SUM(amount),0)").Scan(&stats.TodaySales).Error; err != nil {
return nil, err
}
if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled).
Select("COALESCE(SUM(service_fee_amount),0)").Scan(&stats.TotalFees).Error; err != nil {
return nil, err
}
if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled).
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("order_status AS status, COUNT(*) AS count").
Group("order_status").Scan(&orderStatusCounts).Error; err != nil {
return nil, err
}
for _, item := range orderStatusCounts {
switch item.Status {
case model.OrderStatusPending:
stats.PendingOrderCount = item.Count
case model.OrderStatusPaid:
stats.PaidOrderCount = item.Count
case model.OrderStatusDelivering:
stats.DeliveringOrderCount = item.Count
case model.OrderStatusDelivered:
stats.DeliveredOrderCount = item.Count
case model.OrderStatusShipFailed:
stats.ShipFailedOrderCount = item.Count
case model.OrderStatusCancelled:
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: normalizeOrderStatus(order),
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.OrderStatusDelivered
case "failed":
nextStatus = model.OrderStatusShipFailed
}
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 normalizeOrderStatus(&order) == model.OrderStatusDelivered && in.ShipStatus == "success" {
result = ShipNotifyResult{
OrderNo: order.OrderNo,
Status: normalizeOrderStatus(&order),
Message: "订单已交付,幂等成功",
}
return writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo,
shipNotifyAuditMetadata(in, normalizeOrderStatus(&order), "订单已交付,幂等忽略"))
}
if normalizeOrderStatus(&order) == model.OrderStatusCancelled {
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{}{
"order_status": nextStatus,
}
var message string
switch in.ShipStatus {
case "success":
if err := validateOrderStatusTransition(&order, nextStatus, fulfillmentTransitionShipNotify); err != nil {
message := "当前状态不允许标记发货成功"
if normalizeOrderStatus(&order) == model.OrderStatusPending {
message = "订单未支付,拒绝成功推送"
}
if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil {
return err
}
rejectionErr = err
return nil
}
updates["delivered_at"] = shippedAt
updates["failure_reason"] = ""
if in.ProviderOrderNo != "" {
updates["provider_order_no"] = in.ProviderOrderNo
}
message = "发货成功,订单已交付"
case "failed":
if err := validateOrderStatusTransition(&order, nextStatus, fulfillmentTransitionShipNotify); err != nil {
message := "当前状态不允许标记发货失败"
if normalizeOrderStatus(&order) == model.OrderStatusDelivered {
message = "订单已交付,拒绝失败推送"
} else if normalizeOrderStatus(&order) == model.OrderStatusPending {
message = "订单未支付,拒绝失败推送"
}
if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil {
return err
}
rejectionErr = err
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: 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, normalizeOrderStatus(order), 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"] = timeutil.FormatAPITime(*shippedAt)
}
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)
}
func buildProcessingTimeoutResultData(existing string, timeout time.Duration, now time.Time) string {
m := map[string]interface{}{}
if existing != "" && json.Valid([]byte(existing)) {
_ = json.Unmarshal([]byte(existing), &m)
}
m["timeout"] = true
m["timeout_minutes"] = int(timeout.Minutes())
m["timeout_at"] = timeutil.FormatAPITime(now)
m["ship_status"] = "failed"
m["fail_reason"] = fmt.Sprintf("发货超时:订单已处于 delivering 超过 %d 分钟", int(timeout.Minutes()))
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
}