问题:管理员首页 Dashboard 显示平台总量(37 单,跨全部商户),点进「发货订单」 却只有 29 单,两个数字互相矛盾且界面上没有任何解释。 根因是「平台管理员」在两套接口里语义不同: - /api/dashboard:isPlatformAdmin 只看 JWT 角色 → 平台视角,跨商户聚合 - /api/merchant/*:Tenant 中间件强制落到单个 merchant_id → 商户成员视角。 管理员唯一的成员关系是自营商户,于是被静默框定在商户 1 且 34 条 /platform/* 路由中没有任何订单入口,前端也没有商户切换器或上下文提示。 修复(不改动商户侧隔离语义): - 新增 GET /api/platform/orders,support 按商户/状态/来源/关键字筛选,缺省为全部商户 - Service 抽出共用的 orderFilters 集中校验,ListOrders 与 ListPlatformOrders 并行; ListOrders 仍强制单商户,避免新增平台路径时顺手放宽商户侧可见范围 - FulfillmentOrder 增加 Merchant 关联字段,仅平台侧 Preload,商户侧响应结构不变 - 前端新增「下游订单(全部商户)」页与菜单项,「发货订单」更名为「发货订单(当前商户)」 - README 补充两个订单入口的说明,避免后来者重踩 验证:单测覆盖跨商户可见性、按商户筛选、筛选校验、以及商户侧隔离不回归; 实测新接口返回 37 单(自营 29 + 测试商户 8)且带商户信息,商户侧仍为 29 单且无 merchant 字段。
183 lines
6.2 KiB
Go
183 lines
6.2 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"affiliate_dash/internal/model"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
// orderFilters 是商户侧与平台侧共用的订单筛选条件。
|
|
// 集中在一处校验,避免以后新增筛选项时只改了一边。
|
|
type orderFilters struct {
|
|
// merchantID 为 nil 表示不限商户(仅平台侧使用);指向 0 时仍会按 merchant_id=0 过滤,
|
|
// 以保持 ListOrders 的原有语义不变。
|
|
merchantID *uint
|
|
orderStatus string
|
|
orderSource string
|
|
keyword string
|
|
}
|
|
|
|
func (f orderFilters) apply(tx *gorm.DB) (*gorm.DB, error) {
|
|
if f.merchantID != nil {
|
|
tx = tx.Where("merchant_id = ?", *f.merchantID)
|
|
}
|
|
if f.orderStatus != "" {
|
|
switch f.orderStatus {
|
|
case model.OrderStatusPaid, model.OrderStatusDelivering, model.OrderStatusDelivered, model.OrderStatusShipFailed, model.OrderStatusCancelled:
|
|
default:
|
|
return nil, errors.New("无效的订单状态")
|
|
}
|
|
tx = tx.Where("order_status = ?", f.orderStatus)
|
|
}
|
|
if f.orderSource != "" {
|
|
switch f.orderSource {
|
|
case model.OrderSourceAPI, model.OrderSourceManual, model.OrderSourceTest:
|
|
tx = tx.Where("order_source = ?", f.orderSource)
|
|
default:
|
|
return nil, errors.New("无效的订单来源")
|
|
}
|
|
}
|
|
if keyword := strings.TrimSpace(f.keyword); keyword != "" {
|
|
like := "%" + keyword + "%"
|
|
tx = tx.Where("order_no LIKE ? OR client_order_no LIKE ?", like, like)
|
|
}
|
|
return tx, nil
|
|
}
|
|
|
|
func (s *FulfillmentService) listOrders(f orderFilters, page, size int, withMerchant bool) ([]model.FulfillmentOrder, int64, error) {
|
|
page, size = normalizePage(page, size)
|
|
tx, err := f.apply(s.db.Model(&model.FulfillmentOrder{}))
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
var total int64
|
|
if err := tx.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
query := tx.Preload("MerchantProduct.Product")
|
|
if withMerchant {
|
|
// 平台侧跨商户列表需要展示订单属于哪个商户。
|
|
query = query.Preload("Merchant")
|
|
}
|
|
var orders []model.FulfillmentOrder
|
|
err = query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&orders).Error
|
|
return orders, total, err
|
|
}
|
|
|
|
// ListOrders 商户后台订单列表,强制按单个商户隔离。
|
|
func (s *FulfillmentService) ListOrders(merchantID uint, page, size int, orderStatus, orderSource, keyword string) ([]model.FulfillmentOrder, int64, error) {
|
|
return s.listOrders(orderFilters{
|
|
merchantID: &merchantID,
|
|
orderStatus: orderStatus,
|
|
orderSource: orderSource,
|
|
keyword: keyword,
|
|
}, page, size, false)
|
|
}
|
|
|
|
// ListPlatformOrders 平台侧跨商户订单查询,供超管在管理端查看全部下游订单。
|
|
// merchantID 为 nil 表示不限商户。这是与 ListOrders 并行的另一条路径,
|
|
// 刻意不改动商户侧「必须落到单个商户」的隔离语义。
|
|
func (s *FulfillmentService) ListPlatformOrders(merchantID *uint, page, size int, orderStatus, orderSource, keyword string) ([]model.FulfillmentOrder, int64, error) {
|
|
return s.listOrders(orderFilters{
|
|
merchantID: merchantID,
|
|
orderStatus: orderStatus,
|
|
orderSource: orderSource,
|
|
keyword: keyword,
|
|
}, page, size, true)
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
// 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
|
|
}
|