Files
affiliate_dash/backend/internal/service/fulfillment_query.go
T
yml2213 8c12d1d448 fix(admin): 超管看不到下游订单——新增平台侧跨商户订单视图
问题:管理员首页 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 字段。
2026-10-06 20:28:41 +08:00

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
}