重构: 手续费改为百分比/固定二选一,清理旧 Skin/Order/ShipLog 演示链路
手续费: - 新增 fee_type 字段(rate/fixed),百分比与固定金额二选一,不再叠加 - calculateServiceFee 按 fee_type 分支计算 - 商户创建/更新校验 fee_type,订单落库快照 fee_type - 前端表单改为下拉选择手续费类型,动态显示对应输入框 - 测试拆为 TestFeeRate + TestFeeFixed 清理旧链路: - 删除旧 Skin/Order/ShipLog 模型及 OrderService/SkinService - Dashboard 迁移到 FulfillmentService - 上游 /api/open/v1 改为基于 FulfillmentOrder 实现,接口契约不变 - 推送留痕改用 AuditLog,不再建 ShipLog 表 - 删除前端 Skins/Orders/ShipLogs/Distributors 页面及路由、菜单、API、类型 - 新增迁移 003: 添加 fee_type 列并 DROP 旧表
This commit is contained in:
@@ -79,6 +79,13 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat
|
||||
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).
|
||||
@@ -97,7 +104,15 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat
|
||||
if product.PriceAmount > 0 && in.Quantity > math.MaxInt64/product.PriceAmount {
|
||||
return errors.New("订单金额超出范围")
|
||||
}
|
||||
totalAmount := product.PriceAmount * in.Quantity
|
||||
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"}).
|
||||
@@ -120,6 +135,11 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat
|
||||
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,
|
||||
@@ -141,7 +161,7 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat
|
||||
ReferenceType: "fulfillment_order",
|
||||
ReferenceNo: order.OrderNo,
|
||||
IdempotencyKey: &idempotencyKey,
|
||||
Note: "开放接口下单扣款",
|
||||
Note: "开放接口下单扣款(含平台手续费)",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -493,6 +513,31 @@ func newFulfillmentOrderNo() string {
|
||||
return "FO" + 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, "订单未支付或已退款"
|
||||
@@ -518,8 +563,11 @@ func orderCallbackData(order *model.FulfillmentOrder) map[string]interface{} {
|
||||
"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,
|
||||
"currency": order.Currency,
|
||||
"payment_status": order.PaymentStatus,
|
||||
"fulfillment_status": order.FulfillmentStatus,
|
||||
"can_fulfill": canFulfill,
|
||||
@@ -528,3 +576,358 @@ func orderCallbackData(order *model.FulfillmentOrder) map[string]interface{} {
|
||||
"failure_reason": order.FailureReason,
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 仪表盘统计 -----
|
||||
|
||||
// DashboardStats 仪表盘聚合指标。
|
||||
type DashboardStats struct {
|
||||
ProductCount int64 `json:"product_count"`
|
||||
MerchantCount int64 `json:"merchant_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalSales float64 `json:"total_sales"`
|
||||
TotalFees float64 `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) / 100.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) / 100.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 float64 `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: float64(order.Amount) / 100.0,
|
||||
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,便于后续查询还原(新模型无独立列)。
|
||||
updates["result_data"] = buildShipNotifyResultData(order.ResultData, in, shippedAt)
|
||||
|
||||
if err := s.db.Model(&model.FulfillmentOrder{}).Where("id = ?", order.ID).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.writeShipNotifyAudit(order, in, nextStatus, msg)
|
||||
|
||||
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 {
|
||||
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 writeAudit(s.db, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo, 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user