1091 lines
36 KiB
Go
1091 lines
36 KiB
Go
package service
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"strings"
|
||
"time"
|
||
|
||
"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 := tx.Create(&model.FulfillmentJob{
|
||
MerchantID: in.MerchantID,
|
||
OrderID: order.ID,
|
||
Status: model.FulfillmentJobStatusPending,
|
||
NextRunAt: time.Now(),
|
||
}).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
|
||
}
|
||
jobStatus := model.FulfillmentJobStatusProcessing
|
||
if in.Status == model.FulfillmentStatusSucceeded {
|
||
jobStatus = model.FulfillmentJobStatusSucceeded
|
||
} else if in.Status == model.FulfillmentStatusFailed {
|
||
jobStatus = model.FulfillmentJobStatusFailed
|
||
}
|
||
if err := tx.Model(&model.FulfillmentJob{}).Where("order_id = ?", order.ID).Updates(map[string]interface{}{
|
||
"status": jobStatus,
|
||
"provider_order_no": in.ProviderOrderNo,
|
||
"result_payload": resultData,
|
||
"last_error": in.FailureReason,
|
||
}).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.Model(&model.FulfillmentJob{}).Where("order_id = ?", order.ID).Updates(map[string]interface{}{
|
||
"status": model.FulfillmentJobStatusFailed,
|
||
"last_error": "订单已取消",
|
||
}).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 {
|
||
ProductCount int64 `json:"product_count"`
|
||
MerchantCount int64 `json:"merchant_count"`
|
||
OrderCount int64 `json:"order_count"`
|
||
TotalSales int64 `json:"total_sales"`
|
||
TotalFees int64 `json:"total_fees"`
|
||
PendingOrderCount int64 `json:"pending_order_count"`
|
||
}
|
||
|
||
// Dashboard 汇总商户维度的商品、商户、订单与金额统计。
|
||
func (s *FulfillmentService) Dashboard(merchantID uint, isPlatformAdmin bool) (*DashboardStats, error) {
|
||
stats := &DashboardStats{}
|
||
s.db.Model(&model.MerchantProduct{}).Where("merchant_id = ?", merchantID).Count(&stats.ProductCount)
|
||
if isPlatformAdmin {
|
||
s.db.Model(&model.Merchant{}).Where("status = ?", model.MerchantStatusActive).Count(&stats.MerchantCount)
|
||
} else {
|
||
stats.MerchantCount = 1
|
||
}
|
||
s.db.Model(&model.FulfillmentOrder{}).Where("merchant_id = ?", merchantID).Count(&stats.OrderCount)
|
||
s.db.Model(&model.FulfillmentOrder{}).
|
||
Where("merchant_id = ? AND fulfillment_status = ?", merchantID, model.FulfillmentStatusPending).
|
||
Count(&stats.PendingOrderCount)
|
||
s.db.Model(&model.FulfillmentOrder{}).
|
||
Where("merchant_id = ?", merchantID).
|
||
Where("payment_status = ?", model.PaymentStatusPaid).
|
||
Select("COALESCE(SUM(amount),0)").Scan(&stats.TotalSales)
|
||
s.db.Model(&model.FulfillmentOrder{}).
|
||
Where("merchant_id = ?", merchantID).
|
||
Where("payment_status = ?", model.PaymentStatusPaid).
|
||
Select("COALESCE(SUM(service_fee_amount),0)").Scan(&stats.TotalFees)
|
||
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 / processing
|
||
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) {
|
||
if in.OrderNo == "" {
|
||
return nil, errors.New("订单号不能为空")
|
||
}
|
||
var nextStatus string
|
||
switch in.ShipStatus {
|
||
case "success":
|
||
nextStatus = model.FulfillmentStatusSucceeded
|
||
case "failed":
|
||
nextStatus = model.FulfillmentStatusFailed
|
||
case "processing":
|
||
nextStatus = model.FulfillmentStatusProcessing
|
||
default:
|
||
return nil, errors.New("无效的 ship_status,仅支持 success/failed/processing")
|
||
}
|
||
|
||
order, err := s.GetByOrderNo(in.OrderNo)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 已履约成功:success 推送幂等成功
|
||
if order.FulfillmentStatus == model.FulfillmentStatusSucceeded && in.ShipStatus == "success" {
|
||
_ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "订单已履约成功,幂等忽略")
|
||
return &ShipNotifyResult{
|
||
OrderNo: order.OrderNo,
|
||
Status: fulfillmentStatusToLegacyStatus(order.FulfillmentStatus),
|
||
Message: "订单已交付,幂等成功",
|
||
}, nil
|
||
}
|
||
|
||
// 已取消不允许再推
|
||
if order.FulfillmentStatus == model.FulfillmentStatusCancelled {
|
||
_ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "订单已取消,拒绝更新")
|
||
return nil, errors.New("订单已取消,无法更新发货状态")
|
||
}
|
||
|
||
now := time.Now()
|
||
shippedAt := in.ShippedAt
|
||
if shippedAt == nil && in.ShipStatus == "success" {
|
||
shippedAt = &now
|
||
}
|
||
|
||
updates := map[string]interface{}{
|
||
"fulfillment_status": nextStatus,
|
||
}
|
||
var msg string
|
||
switch in.ShipStatus {
|
||
case "success":
|
||
// 仅 pending / failed / processing 可转为 succeeded
|
||
if order.FulfillmentStatus != model.FulfillmentStatusPending &&
|
||
order.FulfillmentStatus != model.FulfillmentStatusFailed &&
|
||
order.FulfillmentStatus != model.FulfillmentStatusProcessing {
|
||
_ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "当前状态不允许标记发货成功")
|
||
return nil, fmt.Errorf("当前状态 %s 不允许标记发货成功", order.FulfillmentStatus)
|
||
}
|
||
updates["delivered_at"] = shippedAt
|
||
updates["failure_reason"] = ""
|
||
if in.ProviderOrderNo != "" {
|
||
updates["provider_order_no"] = in.ProviderOrderNo
|
||
}
|
||
msg = "发货成功,订单已交付"
|
||
case "failed":
|
||
if order.FulfillmentStatus == model.FulfillmentStatusSucceeded {
|
||
_ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "订单已交付,忽略失败推送")
|
||
return nil, errors.New("订单已交付,不能标记发货失败")
|
||
}
|
||
updates["failure_reason"] = in.FailReason
|
||
if in.ProviderOrderNo != "" {
|
||
updates["provider_order_no"] = in.ProviderOrderNo
|
||
}
|
||
msg = "已记录发货失败"
|
||
case "processing":
|
||
if order.FulfillmentStatus == model.FulfillmentStatusSucceeded {
|
||
_ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "订单已交付,忽略发货中推送")
|
||
return &ShipNotifyResult{
|
||
OrderNo: order.OrderNo,
|
||
Status: fulfillmentStatusToLegacyStatus(order.FulfillmentStatus),
|
||
Message: "订单已交付,忽略 processing",
|
||
}, nil
|
||
}
|
||
if order.FulfillmentStatus != model.FulfillmentStatusPending &&
|
||
order.FulfillmentStatus != model.FulfillmentStatusFailed &&
|
||
order.FulfillmentStatus != model.FulfillmentStatusProcessing {
|
||
_ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "当前状态不允许进入发货中")
|
||
return nil, fmt.Errorf("当前状态 %s 不允许进入发货中", order.FulfillmentStatus)
|
||
}
|
||
if in.ProviderOrderNo != "" {
|
||
updates["provider_order_no"] = in.ProviderOrderNo
|
||
}
|
||
msg = "订单已标记为发货中"
|
||
}
|
||
|
||
resultData := buildShipNotifyResultData(order.ResultData, in, shippedAt)
|
||
updates["result_data"] = resultData
|
||
var updated model.FulfillmentOrder
|
||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||
if err := tx.Model(&model.FulfillmentOrder{}).Where("id = ?", order.ID).Updates(updates).Error; err != nil {
|
||
return err
|
||
}
|
||
jobStatus := model.FulfillmentJobStatusProcessing
|
||
if nextStatus == model.FulfillmentStatusSucceeded {
|
||
jobStatus = model.FulfillmentJobStatusSucceeded
|
||
} else if nextStatus == model.FulfillmentStatusFailed {
|
||
jobStatus = model.FulfillmentJobStatusFailed
|
||
}
|
||
jobUpdates := map[string]interface{}{
|
||
"status": jobStatus,
|
||
"result_payload": resultData,
|
||
"last_error": in.FailReason,
|
||
}
|
||
if in.ProviderOrderNo != "" {
|
||
jobUpdates["provider_order_no"] = in.ProviderOrderNo
|
||
}
|
||
if err := tx.Model(&model.FulfillmentJob{}).Where("order_id = ?", order.ID).Updates(jobUpdates).Error; err != nil {
|
||
return err
|
||
}
|
||
metadata := shipNotifyAuditMetadata(in, nextStatus, msg)
|
||
if err := writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo, metadata); err != nil {
|
||
return err
|
||
}
|
||
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
|
||
}
|
||
}
|
||
return nil
|
||
}); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &ShipNotifyResult{
|
||
OrderNo: order.OrderNo,
|
||
Status: fulfillmentStatusToLegacyStatus(nextStatus),
|
||
Message: msg,
|
||
}, nil
|
||
}
|
||
|
||
// writeShipNotifyAudit 将上游推送留痕写入 AuditLog(替代旧的 ShipLog 表)。
|
||
func (s *FulfillmentService) writeShipNotifyAudit(order *model.FulfillmentOrder, in ShipNotifyInput, resultStatus, message string) error {
|
||
return writeAudit(s.db, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo, shipNotifyAuditMetadata(in, resultStatus, 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.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
|
||
}
|
||
}
|