重构: 手续费改为百分比/固定二选一,清理旧 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:
yml2213
2026-07-30 14:12:27 +08:00
parent 83429456a6
commit bafe41a3ea
36 changed files with 1247 additions and 2446 deletions
+406 -3
View File
@@ -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
}
}