diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 429cd5c..264e275 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -222,6 +222,60 @@ func Setup(h *Handlers) *gin.Engine { clientOpen.GET("/wallet", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireAPIScope("wallet:read"), h.Open.GetWallet) } + // 平台管理端:只依赖 JWT 与角色,刻意不挂 Tenant。 + // 平台接口本身跨商户,一旦让它们解析 X-Merchant-ID,管理员哪怕只是残留一个已禁用 + // 商户的上下文,Tenant 就会对全部请求返回 403,整个管理端都会被锁死。 + platform := api.Group("") + platform.Use(middleware.Auth(h.JWT)) + platform.Use(middleware.RequireRole(model.RoleAdmin)) + { + platform.GET("/platform/admins", h.User.ListPlatformAdmins) + platform.GET("/platform/merchant-accounts", h.User.ListMerchantAccountGroups) + platform.DELETE("/platform/merchant-accounts/:id", h.User.DeleteMerchantAccount) + platform.POST("/platform/admins", h.User.CreatePlatformAdmin) + platform.PATCH("/platform/admins/:id", h.User.UpdatePlatformAdmin) + platform.DELETE("/platform/admins/:id", h.User.DeletePlatformAdmin) + platform.GET("/platform/merchants", h.Merchant.ListPlatformMerchants) + // 跨商户订单视图:商户后台的 /api/merchant/orders 强制单商户隔离, + // 超管需要一条平台视角入口来查看全部下游订单。 + platform.GET("/platform/orders", h.Merchant.ListPlatformOrders) + platform.POST("/platform/merchants", h.Merchant.CreateMerchant) + platform.PATCH("/platform/merchants/:id", h.Merchant.UpdateMerchantSettings) + platform.POST("/platform/merchants/:id/members", h.Merchant.AddPlatformMerchantMember) + platform.GET("/platform/product-catalog", h.Merchant.ListProductCatalog) + platform.GET("/platform/merchants/:id/products", h.Merchant.ListMerchantProductsByAdmin) + platform.PATCH("/platform/merchants/:id/products/:pid", h.Merchant.AdminUpdateMerchantProduct) + platform.POST("/platform/merchants/:id/products/assign", h.Merchant.AssignProducts) + platform.GET("/platform/merchants/:id/wallet", h.Merchant.AdminGetWallet) + platform.GET("/platform/merchants/:id/account", h.Merchant.AdminGetMerchantAccount) + platform.PATCH("/platform/merchants/:id/account", h.Merchant.AdminUpdateMerchantAccount) + platform.GET("/platform/merchants/:id/wallet/ledger", h.Merchant.AdminListWalletLedger) + platform.POST("/platform/merchants/:id/wallet/adjust", h.Merchant.AdminAdjustWallet) + platform.GET("/platform/recharge/applications", h.Recharge.AdminListApplications) + platform.POST("/platform/recharge/applications/:id/review", h.Recharge.AdminReviewApplication) + + // 乐玩平台商品拉取、同步与履约路由配置 + if h.PlatformLewan != nil { + platform.GET("/platform/lewan/products", h.PlatformLewan.ListLewanProducts) + platform.POST("/platform/lewan/sync-catalog", h.PlatformLewan.SyncLewanProduct) + platform.POST("/platform/lewan/batch-sync-catalog", h.PlatformLewan.BatchSyncLewanProducts) + platform.PUT("/platform/products/:id/routing", h.PlatformLewan.UpdateProductRouting) + } + + // Rock平台 (0666.pro) 自动发货与库存风控管理 + if h.Rock != nil { + platform.GET("/platform/rock/config", h.Rock.GetConfig) + platform.PUT("/platform/rock/config", h.Rock.UpdateConfig) + platform.POST("/platform/rock/test-conn", h.Rock.TestConnection) + platform.GET("/platform/rock/stats", h.Rock.GetStats) + platform.POST("/platform/rock/purchase", h.Rock.PurchaseTokens) + platform.GET("/platform/rock/tokens", h.Rock.ListTokens) + platform.POST("/platform/rock/tokens/:id/expected-uid", h.Rock.CorrectTokenUID) + platform.POST("/platform/rock/tokens/:id/reset", h.Rock.ResetToken) + platform.POST("/platform/rock/tokens/:id/release", h.Rock.ReleaseToken) + } + } + auth := api.Group("") auth.Use(middleware.Auth(h.JWT)) auth.Use(middleware.Tenant(h.Tenant)) @@ -273,57 +327,6 @@ func Setup(h *Handlers) *gin.Engine { merchant.PATCH("/roles/:id", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.UpdateRole) } - // 平台管理员 / 发货记录 / 平台商户(仅管理员) - admin := auth.Group("") - admin.Use(middleware.RequireRole(model.RoleAdmin)) - { - admin.GET("/platform/admins", h.User.ListPlatformAdmins) - admin.GET("/platform/merchant-accounts", h.User.ListMerchantAccountGroups) - admin.DELETE("/platform/merchant-accounts/:id", h.User.DeleteMerchantAccount) - admin.POST("/platform/admins", h.User.CreatePlatformAdmin) - admin.PATCH("/platform/admins/:id", h.User.UpdatePlatformAdmin) - admin.DELETE("/platform/admins/:id", h.User.DeletePlatformAdmin) - admin.GET("/platform/merchants", h.Merchant.ListPlatformMerchants) - // 跨商户订单视图:商户后台的 /api/merchant/orders 强制单商户隔离, - // 超管需要一条平台视角入口来查看全部下游订单。 - admin.GET("/platform/orders", h.Merchant.ListPlatformOrders) - admin.POST("/platform/merchants", h.Merchant.CreateMerchant) - admin.PATCH("/platform/merchants/:id", h.Merchant.UpdateMerchantSettings) - admin.POST("/platform/merchants/:id/members", h.Merchant.AddPlatformMerchantMember) - admin.GET("/platform/product-catalog", h.Merchant.ListProductCatalog) - admin.GET("/platform/merchants/:id/products", h.Merchant.ListMerchantProductsByAdmin) - admin.PATCH("/platform/merchants/:id/products/:pid", h.Merchant.AdminUpdateMerchantProduct) - admin.POST("/platform/merchants/:id/products/assign", h.Merchant.AssignProducts) - admin.GET("/platform/merchants/:id/wallet", h.Merchant.AdminGetWallet) - admin.GET("/platform/merchants/:id/account", h.Merchant.AdminGetMerchantAccount) - admin.PATCH("/platform/merchants/:id/account", h.Merchant.AdminUpdateMerchantAccount) - admin.GET("/platform/merchants/:id/wallet/ledger", h.Merchant.AdminListWalletLedger) - admin.POST("/platform/merchants/:id/wallet/adjust", h.Merchant.AdminAdjustWallet) - admin.GET("/platform/recharge/applications", h.Recharge.AdminListApplications) - admin.POST("/platform/recharge/applications/:id/review", h.Recharge.AdminReviewApplication) - - // 乐玩平台商品拉取、同步与履约路由配置 - if h.PlatformLewan != nil { - admin.GET("/platform/lewan/products", h.PlatformLewan.ListLewanProducts) - admin.POST("/platform/lewan/sync-catalog", h.PlatformLewan.SyncLewanProduct) - admin.POST("/platform/lewan/batch-sync-catalog", h.PlatformLewan.BatchSyncLewanProducts) - admin.PUT("/platform/products/:id/routing", h.PlatformLewan.UpdateProductRouting) - } - - // Rock平台 (0666.pro) 自动发货与库存风控管理 - if h.Rock != nil { - admin.GET("/platform/rock/config", h.Rock.GetConfig) - admin.PUT("/platform/rock/config", h.Rock.UpdateConfig) - admin.POST("/platform/rock/test-conn", h.Rock.TestConnection) - admin.GET("/platform/rock/stats", h.Rock.GetStats) - admin.POST("/platform/rock/purchase", h.Rock.PurchaseTokens) - admin.GET("/platform/rock/tokens", h.Rock.ListTokens) - admin.POST("/platform/rock/tokens/:id/expected-uid", h.Rock.CorrectTokenUID) - admin.POST("/platform/rock/tokens/:id/reset", h.Rock.ResetToken) - admin.POST("/platform/rock/tokens/:id/release", h.Rock.ReleaseToken) - } - } - auth.POST("/upload", h.Recharge.Upload) } } diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go index 45d9d69..edb48e9 100644 --- a/backend/internal/router/router_test.go +++ b/backend/internal/router/router_test.go @@ -8,6 +8,7 @@ import ( "testing" "affiliate_dash/internal/handler" + "affiliate_dash/internal/model" "affiliate_dash/internal/pkg/applog" "affiliate_dash/internal/pkg/jwt" "affiliate_dash/internal/pkg/openlog" @@ -17,15 +18,18 @@ import ( "github.com/gin-gonic/gin" ) -func TestSetupDoesNotPanic(t *testing.T) { +// newTestRouter 构建一个接好全部依赖的测试路由。 +func newTestRouter(t *testing.T) (*gin.Engine, *jwt.Manager) { + t.Helper() gin.SetMode(gin.TestMode) db := testdb.New(t) codec, err := service.NewSecretCodec("test-master-key") if err != nil { t.Fatalf("codec: %v", err) } + jm := jwt.NewManager("test-jwt") tenantSvc := service.NewTenantService(db) - authSvc := service.NewAuthService(db, jwt.NewManager("test-jwt"), tenantSvc) + authSvc := service.NewAuthService(db, jm, tenantSvc) userSvc := service.NewUserService(db, tenantSvc) callbackSvc := service.NewCallbackService(db, codec) fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc) @@ -33,12 +37,7 @@ func TestSetupDoesNotPanic(t *testing.T) { rechargeSvc := service.NewRechargeService(db, fulfillmentSvc, "") deliverySvc := service.NewDeliveryService(fulfillmentSvc, "", "", "", "", 0) - defer func() { - if recovered := recover(); recovered != nil { - t.Fatalf("setup should not panic: %v", recovered) - } - }() - _ = Setup(&Handlers{ + engine := Setup(&Handlers{ Auth: handler.NewAuthHandler(authSvc), Dashboard: handler.NewDashboardHandler(fulfillmentSvc), User: handler.NewUserHandler(userSvc), @@ -46,7 +45,7 @@ func TestSetupDoesNotPanic(t *testing.T) { SourceOpen: handler.NewOpenHandler(fulfillmentSvc), Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc, deliverySvc, rechargeSvc), Recharge: handler.NewRechargeHandler(rechargeSvc), - JWT: jwt.NewManager("test-jwt"), + JWT: jm, Tenant: tenantSvc, OpenDB: db, SecretCodec: codec, @@ -54,6 +53,72 @@ func TestSetupDoesNotPanic(t *testing.T) { OpenAPISecret: "source-secret", OpenSignSkew: 300, }) + return engine, jm +} + +func TestSetupDoesNotPanic(t *testing.T) { + defer func() { + if recovered := recover(); recovered != nil { + t.Fatalf("setup should not panic: %v", recovered) + } + }() + _, _ = newTestRouter(t) +} + +// TestPlatformRoutesIgnoreStaleMerchantContext 卡住一个会把整个管理端锁死的场景: +// 平台路由一旦挂上 Tenant,管理员 localStorage 里残留一个已不存在/已禁用的商户 ID 时, +// 所有 /api/platform/* 请求都会被 403,连"去改回来"的页面都打不开。 +// 因此平台分组只挂 Auth + RequireRole,不解析商户上下文。 +func TestPlatformRoutesIgnoreStaleMerchantContext(t *testing.T) { + engine, jm := newTestRouter(t) + token, err := jm.Generate(1, "admin", model.RoleAdmin) + if err != nil { + t.Fatalf("generate token: %v", err) + } + + cases := []struct { + name string + merchantHeader string + }{ + {name: "无商户上下文", merchantHeader: ""}, + {name: "残留不存在的商户", merchantHeader: "999999"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/platform/merchants", nil) + req.Header.Set("Authorization", "Bearer "+token) + if tc.merchantHeader != "" { + req.Header.Set("X-Merchant-ID", tc.merchantHeader) + } + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("平台路由必须不受残留商户上下文影响,期望 200,实际 %d:%s", w.Code, w.Body.String()) + } + }) + } +} + +// TestMerchantRoutesStillRejectStaleContext 反向卡住商户侧: +// 摘掉 Tenant 只针对平台分组,商户后台仍必须校验商户上下文。 +func TestMerchantRoutesStillRejectStaleContext(t *testing.T) { + engine, jm := newTestRouter(t) + token, err := jm.Generate(1, "admin", model.RoleAdmin) + if err != nil { + t.Fatalf("generate token: %v", err) + } + + req := httptest.NewRequest("GET", "/api/merchant", nil) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("X-Merchant-ID", "999999") + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != 403 { + t.Fatalf("商户路由必须拒绝无效商户上下文,期望 403,实际 %d:%s", w.Code, w.Body.String()) + } } func TestIsPollingEndpoint(t *testing.T) { diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index 5171b01..7b226b0 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -6,13 +6,28 @@ const request = axios.create({ timeout: 15000, }) +function currentRole(): string { + try { + return JSON.parse(localStorage.getItem('user') || 'null')?.role || '' + } catch { + return '' + } +} + request.interceptors.request.use((config) => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } + // 对「商户上下文本来就不起作用」的接口,刻意不带 X-Merchant-ID: + // - /platform/*:平台侧接口本身就是跨商户的; + // - 管理员的 /dashboard:后端按角色判定为平台口径,压根不看商户。 + // 更要紧的是,一旦 localStorage 里残留的是已禁用/已删除的商户,服务端会对这些 + // 本该正常的请求直接 403。后端已把平台路由从租户中间件里摘出来,这里是第二道防线。 + const url = config.url || '' + const merchantContextIrrelevant = url.startsWith('/platform/') || (url === '/dashboard' && currentRole() === 'admin') const merchantId = localStorage.getItem('merchant_id') - if (merchantId) { + if (merchantId && !merchantContextIrrelevant) { config.headers['X-Merchant-ID'] = merchantId } return config