From a4791decb0dbdcd841827ea558304f58cdd4a806 Mon Sep 17 00:00:00 2001
From: yml2213
Date: Thu, 13 Aug 2026 11:41:34 +0800
Subject: [PATCH] feat: support merchant team roles and permissions
---
.../migrations/007_merchant_roles.sql | 16 ++
.../008_default_merchant_team_roles.sql | 11 ++
backend/internal/handler/merchant.go | 76 ++++++-
backend/internal/handler/order.go | 42 +++-
backend/internal/middleware/auth.go | 56 +++++-
backend/internal/model/merchant.go | 21 ++
backend/internal/router/router.go | 57 +++---
backend/internal/service/merchant.go | 3 +
backend/internal/service/merchant_member.go | 186 ++++++++++++++++--
backend/internal/service/merchant_test.go | 94 +++++++++
backend/internal/service/tenant.go | 52 ++++-
frontend/src/App.tsx | 53 +++--
frontend/src/api/index.ts | 12 +-
frontend/src/layouts/MainLayout.tsx | 35 ++--
frontend/src/pages/Dashboard.tsx | 169 +++++++++-------
frontend/src/pages/MerchantCenter.tsx | 139 +++++++++----
frontend/src/pages/PlatformMerchants.tsx | 14 +-
frontend/src/pages/merchantCenterTabs.tsx | 45 +++--
frontend/src/pages/merchantCenterUtils.ts | 17 +-
frontend/src/store/auth.tsx | 60 +++++-
frontend/src/types/index.ts | 12 +-
21 files changed, 969 insertions(+), 201 deletions(-)
create mode 100644 backend/internal/database/migrations/007_merchant_roles.sql
create mode 100644 backend/internal/database/migrations/008_default_merchant_team_roles.sql
diff --git a/backend/internal/database/migrations/007_merchant_roles.sql b/backend/internal/database/migrations/007_merchant_roles.sql
new file mode 100644
index 0000000..e0e51fb
--- /dev/null
+++ b/backend/internal/database/migrations/007_merchant_roles.sql
@@ -0,0 +1,16 @@
+-- 商户自定义员工角色。role code 仅在所属商户内唯一,权限以逗号分隔保存。
+CREATE TABLE IF NOT EXISTS merchant_roles (
+ id BIGSERIAL PRIMARY KEY,
+ created_at TIMESTAMPTZ,
+ updated_at TIMESTAMPTZ,
+ merchant_id BIGINT NOT NULL,
+ code VARCHAR(64) NOT NULL,
+ name VARCHAR(64) NOT NULL,
+ permissions TEXT NOT NULL DEFAULT '',
+ status BIGINT NOT NULL DEFAULT 1
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_merchant_role_code
+ ON merchant_roles (merchant_id, code);
+CREATE INDEX IF NOT EXISTS idx_merchant_roles_merchant_id
+ ON merchant_roles (merchant_id);
diff --git a/backend/internal/database/migrations/008_default_merchant_team_roles.sql b/backend/internal/database/migrations/008_default_merchant_team_roles.sql
new file mode 100644
index 0000000..ddc9f65
--- /dev/null
+++ b/backend/internal/database/migrations/008_default_merchant_team_roles.sql
@@ -0,0 +1,11 @@
+-- 为已有商户补齐可编辑的默认团队角色。负责人仍是系统保留角色,不落库。
+INSERT INTO merchant_roles (merchant_id, code, name, permissions, status)
+SELECT merchants.id, roles.code, roles.name, roles.permissions, 1
+FROM merchants
+CROSS JOIN (
+ VALUES
+ ('operator', '运营', 'api:manage,callbacks:manage,orders:manage,products:manage,wallet:view'),
+ ('finance', '财务', 'recharge:manage,wallet:ledger,wallet:view'),
+ ('support', '客服', 'orders:manage')
+) AS roles(code, name, permissions)
+ON CONFLICT (merchant_id, code) DO NOTHING;
diff --git a/backend/internal/handler/merchant.go b/backend/internal/handler/merchant.go
index afc41e9..3efc49b 100644
--- a/backend/internal/handler/merchant.go
+++ b/backend/internal/handler/merchant.go
@@ -1,6 +1,7 @@
package handler
import (
+ "sort"
"strconv"
"strings"
"time"
@@ -38,11 +39,21 @@ func (h *MerchantHandler) Current(c *gin.Context) {
return
}
response.OK(c, gin.H{
- "merchant": merchant,
- "role": middleware.GetMerchantRole(c),
+ "merchant": merchant,
+ "role": middleware.GetMerchantRole(c),
+ "permissions": mapKeys(middleware.GetMerchantPermissions(c)),
})
}
+func mapKeys(values map[string]struct{}) []string {
+ keys := make([]string, 0, len(values))
+ for key := range values {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ return keys
+}
+
func (h *MerchantHandler) ListProducts(c *gin.Context) {
page, size := pageParams(c)
list, total, err := h.merchantSvc.ListMerchantProducts(middleware.GetMerchantID(c), page, size, false)
@@ -398,7 +409,10 @@ func (h *MerchantHandler) ListMembers(c *gin.Context) {
}
type addMemberReq struct {
- UserID uint `json:"user_id" binding:"required"`
+ UserID uint `json:"user_id"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ Nickname string `json:"nickname"`
Role string `json:"role" binding:"required"`
IsDefault bool `json:"is_default"`
}
@@ -406,11 +420,14 @@ type addMemberReq struct {
func (h *MerchantHandler) AddCurrentMerchantMember(c *gin.Context) {
var req addMemberReq
if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "参数错误:user_id 与 role 必填")
+ response.BadRequest(c, "参数错误:角色必填")
return
}
member, err := h.merchantSvc.AddMember(middleware.GetMerchantID(c), service.AddMemberInput{
UserID: req.UserID,
+ Username: req.Username,
+ Password: req.Password,
+ Nickname: req.Nickname,
Role: req.Role,
IsDefault: req.IsDefault,
}, middleware.GetUserID(c))
@@ -421,6 +438,54 @@ func (h *MerchantHandler) AddCurrentMerchantMember(c *gin.Context) {
response.OK(c, member)
}
+func (h *MerchantHandler) ListRoles(c *gin.Context) {
+ roles, err := h.merchantSvc.ListRoles(middleware.GetMerchantID(c))
+ if err != nil {
+ response.ServerError(c, err.Error())
+ return
+ }
+ response.OK(c, roles)
+}
+
+type createMerchantRoleReq struct {
+ Code string `json:"code" binding:"required"`
+ Name string `json:"name" binding:"required"`
+ Permissions []string `json:"permissions"`
+}
+
+func (h *MerchantHandler) CreateRole(c *gin.Context) {
+ var req createMerchantRoleReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "参数错误:角色编码和名称必填")
+ return
+ }
+ role, err := h.merchantSvc.CreateRole(middleware.GetMerchantID(c), service.MerchantRoleInput{
+ Code: req.Code, Name: req.Name, Permissions: req.Permissions,
+ }, middleware.GetUserID(c))
+ if err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+ response.OK(c, role)
+}
+
+func (h *MerchantHandler) UpdateRole(c *gin.Context) {
+ roleID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ var req createMerchantRoleReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "参数错误:角色名称必填")
+ return
+ }
+ role, err := h.merchantSvc.UpdateRole(middleware.GetMerchantID(c), uint(roleID), service.MerchantRoleInput{
+ Name: req.Name, Permissions: req.Permissions,
+ }, middleware.GetUserID(c))
+ if err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+ response.OK(c, role)
+}
+
func (h *MerchantHandler) ListPlatformMerchants(c *gin.Context) {
page, size := pageParams(c)
list, total, err := h.merchantSvc.ListMerchants(page, size)
@@ -595,6 +660,9 @@ func (h *MerchantHandler) AddPlatformMerchantMember(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
member, err := h.merchantSvc.AddMember(uint(id), service.AddMemberInput{
UserID: req.UserID,
+ Username: req.Username,
+ Password: req.Password,
+ Nickname: req.Nickname,
Role: req.Role,
IsDefault: req.IsDefault,
}, middleware.GetUserID(c))
diff --git a/backend/internal/handler/order.go b/backend/internal/handler/order.go
index 18deb41..68e5187 100644
--- a/backend/internal/handler/order.go
+++ b/backend/internal/handler/order.go
@@ -19,10 +19,50 @@ func NewDashboardHandler(svc *service.FulfillmentService) *DashboardHandler {
}
func (h *DashboardHandler) Dashboard(c *gin.Context) {
- stats, err := h.svc.Dashboard(middleware.GetMerchantID(c), middleware.GetRole(c) == model.RoleAdmin)
+ isAdmin := middleware.GetRole(c) == model.RoleAdmin
+ stats, err := h.svc.Dashboard(middleware.GetMerchantID(c), isAdmin)
if err != nil {
response.ServerError(c, err.Error())
return
}
+ if !isAdmin {
+ permissions := middleware.GetMerchantPermissions(c)
+ if _, ok := permissions[model.PermissionProductsManage]; !ok {
+ stats.CatalogProductCount = 0
+ stats.ProductCount = 0
+ stats.ActiveProductCount = 0
+ }
+ if _, ok := permissions[model.PermissionMembersManage]; !ok {
+ stats.UserCount = 0
+ }
+ if _, ok := permissions[model.PermissionOrdersManage]; !ok {
+ stats.OrderCount = 0
+ stats.TodayOrderCount = 0
+ stats.PaidOrderCount = 0
+ stats.DeliveringOrderCount = 0
+ stats.DeliveredOrderCount = 0
+ stats.ShipFailedOrderCount = 0
+ stats.CancelledOrderCount = 0
+ }
+ if _, ok := permissions[model.PermissionWalletView]; !ok {
+ stats.TotalSales = 0
+ stats.TodaySales = 0
+ stats.TotalFees = 0
+ stats.TodayFees = 0
+ }
+ if _, ok := permissions[model.PermissionWalletView]; !ok {
+ stats.WalletAvailableBalance = 0
+ stats.WalletFrozenBalance = 0
+ }
+ if _, ok := permissions[model.PermissionAPIManage]; !ok {
+ stats.APIClientCount = 0
+ stats.ActiveAPIClientCount = 0
+ }
+ if _, ok := permissions[model.PermissionCallbacksManage]; !ok {
+ stats.CallbackSubscriptionCount = 0
+ stats.PendingCallbackCount = 0
+ stats.FailedCallbackCount = 0
+ }
+ }
response.OK(c, stats)
}
diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go
index b9cfc18..6619e53 100644
--- a/backend/internal/middleware/auth.go
+++ b/backend/internal/middleware/auth.go
@@ -3,6 +3,7 @@ package middleware
import (
"strings"
+ "affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/jwt"
"affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/service"
@@ -11,10 +12,11 @@ import (
)
const (
- CtxUserID = "user_id"
- CtxUsername = "username"
- CtxRole = "role"
- CtxMerchantRole = "merchant_role"
+ CtxUserID = "user_id"
+ CtxUsername = "username"
+ CtxRole = "role"
+ CtxMerchantRole = "merchant_role"
+ CtxMerchantPermissions = "merchant_permissions"
)
func Auth(jm *jwt.Manager) gin.HandlerFunc {
@@ -77,7 +79,22 @@ func GetRole(c *gin.Context) string {
// 未指定时选择该账号的默认商户,确保旧后台继续落到“自营商户”。
func Tenant(tenantSvc *service.TenantService) gin.HandlerFunc {
return func(c *gin.Context) {
- member, err := tenantSvc.ResolveMember(GetUserID(c), c.GetHeader("X-Merchant-ID"))
+ merchantRef := c.GetHeader("X-Merchant-ID")
+ if GetRole(c) == model.RoleAdmin && merchantRef != "" {
+ merchant, err := tenantSvc.ResolveMerchantForAdmin(merchantRef)
+ if err != nil {
+ response.Forbidden(c, err.Error())
+ c.Abort()
+ return
+ }
+ c.Set(CtxMerchantID, merchant.ID)
+ c.Set(CtxMerchantRole, model.MemberRoleOwner)
+ permissions, _ := tenantSvc.MerchantRolePermissions(merchant.ID, model.MemberRoleOwner)
+ c.Set(CtxMerchantPermissions, permissions)
+ c.Next()
+ return
+ }
+ member, err := tenantSvc.ResolveMember(GetUserID(c), merchantRef)
if err != nil {
response.Forbidden(c, err.Error())
c.Abort()
@@ -85,6 +102,13 @@ func Tenant(tenantSvc *service.TenantService) gin.HandlerFunc {
}
c.Set(CtxMerchantID, member.MerchantID)
c.Set(CtxMerchantRole, member.Role)
+ permissions, err := tenantSvc.MerchantRolePermissions(member.MerchantID, member.Role)
+ if err != nil {
+ response.Forbidden(c, err.Error())
+ c.Abort()
+ return
+ }
+ c.Set(CtxMerchantPermissions, permissions)
c.Next()
}
}
@@ -106,6 +130,28 @@ func RequireMerchantRole(roles ...string) gin.HandlerFunc {
}
}
+// RequireMerchantPermissions 按当前商户的角色权限校验。保留角色通过兼容权限集计算,
+// 自定义角色从 merchant_roles 读取,所有列出的权限均需具备。
+func RequireMerchantPermissions(tenantSvc *service.TenantService, permissions ...string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ granted := GetMerchantPermissions(c)
+ for _, permission := range permissions {
+ if _, ok := granted[permission]; !ok {
+ response.Forbidden(c, "商户权限不足")
+ c.Abort()
+ return
+ }
+ }
+ c.Next()
+ }
+}
+
+func GetMerchantPermissions(c *gin.Context) map[string]struct{} {
+ value, _ := c.Get(CtxMerchantPermissions)
+ permissions, _ := value.(map[string]struct{})
+ return permissions
+}
+
func GetMerchantRole(c *gin.Context) string {
value, _ := c.Get(CtxMerchantRole)
role, _ := value.(string)
diff --git a/backend/internal/model/merchant.go b/backend/internal/model/merchant.go
index 5cf3c45..5770322 100644
--- a/backend/internal/model/merchant.go
+++ b/backend/internal/model/merchant.go
@@ -25,6 +25,15 @@ const (
MemberRoleFinance = "finance"
MemberRoleViewer = "viewer"
+ PermissionMembersManage = "members:manage"
+ PermissionProductsManage = "products:manage"
+ PermissionOrdersManage = "orders:manage"
+ PermissionWalletView = "wallet:view"
+ PermissionWalletLedger = "wallet:ledger"
+ PermissionRechargeManage = "recharge:manage"
+ PermissionAPIManage = "api:manage"
+ PermissionCallbacksManage = "callbacks:manage"
+
FeeTypeRate = "rate" // 手续费按百分比
FeeTypeFixed = "fixed" // 手续费按固定金额
@@ -104,6 +113,18 @@ type MerchantMember struct {
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}
+// MerchantRole 是商户内可配置的员工角色;负责人仍是系统保留角色。
+type MerchantRole struct {
+ ID uint `gorm:"primarykey" json:"id"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ MerchantID uint `gorm:"not null;uniqueIndex:idx_merchant_role_code;index" json:"merchant_id"`
+ Code string `gorm:"size:64;not null;uniqueIndex:idx_merchant_role_code" json:"code"`
+ Name string `gorm:"size:64;not null" json:"name"`
+ Permissions string `gorm:"type:text;not null;default:''" json:"permissions"`
+ Status int `gorm:"not null;default:1" json:"status"`
+}
+
// APIClient 为一个商户的外部系统集成凭证。SecretCiphertext 仅保存加密后的密钥。
type APIClient struct {
ID uint `gorm:"primarykey" json:"id"`
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index 90f7545..efe2880 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -152,33 +152,36 @@ func Setup(h *Handlers) *gin.Engine {
merchant := auth.Group("/merchant")
{
merchant.GET("", h.Merchant.Current)
- merchant.GET("/products", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), h.Merchant.ListProducts)
- merchant.PATCH("/products/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateProductStatus)
- merchant.GET("/orders", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), h.Merchant.ListOrders)
- merchant.POST("/orders/manual", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateManualOrder)
- merchant.POST("/orders/test", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateTestOrder)
- merchant.GET("/orders/:order_no/delivery-link", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.GetDeliveryLink)
- merchant.POST("/orders/:order_no/delivery-link/revoke", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.RevokeDeliveryLink)
- merchant.POST("/orders/:order_no/delivery-link/restore", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.RestoreDeliveryLink)
- merchant.GET("/wallet", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator, model.MemberRoleFinance), h.Merchant.GetWallet)
- merchant.GET("/wallet/ledger", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Merchant.ListWalletLedger)
- merchant.POST("/recharge/applications", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.CreateApplication)
- merchant.GET("/recharge/applications", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator, model.MemberRoleFinance), h.Recharge.ListApplications)
- merchant.GET("/recharge/alert-config", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.GetAlertConfig)
- merchant.PUT("/recharge/alert-config", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.SaveAlertConfig)
- merchant.GET("/alert-channels", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.ListAlertChannels)
- merchant.POST("/alert-channels", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.CreateAlertChannel)
- merchant.PUT("/alert-channels/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.UpdateAlertChannel)
- merchant.DELETE("/alert-channels/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.DeleteAlertChannel)
- merchant.POST("/alert-channels/:id/test", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.TestAlertChannel)
- merchant.GET("/api-clients", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), h.Merchant.ListAPIClients)
- merchant.POST("/api-clients", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateAPIClient)
- merchant.PATCH("/api-clients/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateAPIClientStatus)
- merchant.DELETE("/api-clients/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.DeleteAPIClient)
- merchant.GET("/callbacks", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureCallbacks), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.ListCallbacks)
- merchant.POST("/callbacks", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureCallbacks), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateCallback)
- merchant.GET("/members", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.ListMembers)
- merchant.POST("/members", middleware.RequireMerchantRole(model.MemberRoleOwner), h.Merchant.AddCurrentMerchantMember)
+ merchant.GET("/products", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionProductsManage), h.Merchant.ListProducts)
+ merchant.PATCH("/products/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionProductsManage), h.Merchant.UpdateProductStatus)
+ merchant.GET("/orders", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.ListOrders)
+ merchant.POST("/orders/manual", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.CreateManualOrder)
+ merchant.POST("/orders/test", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.CreateTestOrder)
+ merchant.GET("/orders/:order_no/delivery-link", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.GetDeliveryLink)
+ merchant.POST("/orders/:order_no/delivery-link/revoke", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.RevokeDeliveryLink)
+ merchant.POST("/orders/:order_no/delivery-link/restore", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.RestoreDeliveryLink)
+ merchant.GET("/wallet", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionWalletView), h.Merchant.GetWallet)
+ merchant.GET("/wallet/ledger", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionWalletLedger), h.Merchant.ListWalletLedger)
+ merchant.POST("/recharge/applications", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionRechargeManage), h.Recharge.CreateApplication)
+ merchant.GET("/recharge/applications", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionWalletView), h.Recharge.ListApplications)
+ merchant.GET("/recharge/alert-config", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionRechargeManage), h.Recharge.GetAlertConfig)
+ merchant.PUT("/recharge/alert-config", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionRechargeManage), h.Recharge.SaveAlertConfig)
+ merchant.GET("/alert-channels", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionRechargeManage), h.Recharge.ListAlertChannels)
+ merchant.POST("/alert-channels", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionRechargeManage), h.Recharge.CreateAlertChannel)
+ merchant.PUT("/alert-channels/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionRechargeManage), h.Recharge.UpdateAlertChannel)
+ merchant.DELETE("/alert-channels/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionRechargeManage), h.Recharge.DeleteAlertChannel)
+ merchant.POST("/alert-channels/:id/test", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionRechargeManage), h.Recharge.TestAlertChannel)
+ merchant.GET("/api-clients", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionAPIManage), h.Merchant.ListAPIClients)
+ merchant.POST("/api-clients", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionAPIManage), h.Merchant.CreateAPIClient)
+ merchant.PATCH("/api-clients/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionAPIManage), h.Merchant.UpdateAPIClientStatus)
+ merchant.DELETE("/api-clients/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionAPIManage), h.Merchant.DeleteAPIClient)
+ merchant.GET("/callbacks", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureCallbacks), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionCallbacksManage), h.Merchant.ListCallbacks)
+ merchant.POST("/callbacks", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureCallbacks), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionCallbacksManage), h.Merchant.CreateCallback)
+ merchant.GET("/members", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.ListMembers)
+ merchant.POST("/members", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.AddCurrentMerchantMember)
+ merchant.GET("/roles", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.ListRoles)
+ merchant.POST("/roles", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.CreateRole)
+ merchant.PATCH("/roles/:id", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.UpdateRole)
}
// 用户 / 发货记录 / 平台商户(仅管理员)
diff --git a/backend/internal/service/merchant.go b/backend/internal/service/merchant.go
index 33a4c78..b2829ab 100644
--- a/backend/internal/service/merchant.go
+++ b/backend/internal/service/merchant.go
@@ -99,6 +99,9 @@ func (s *MerchantService) CreateMerchant(in CreateMerchantInput, actorUserID uin
}).Error; err != nil {
return err
}
+ if err := createDefaultMerchantRoles(tx, merchant.ID); err != nil {
+ return err
+ }
if err := copyDefaultProducts(tx, merchant.ID); err != nil {
return err
}
diff --git a/backend/internal/service/merchant_member.go b/backend/internal/service/merchant_member.go
index aedde28..0d0f736 100644
--- a/backend/internal/service/merchant_member.go
+++ b/backend/internal/service/merchant_member.go
@@ -3,39 +3,201 @@ package service
import (
"errors"
"fmt"
+ "regexp"
+ "sort"
+ "strings"
"affiliate_dash/internal/model"
+ "golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type AddMemberInput struct {
UserID uint
+ Username string
+ Password string
+ Nickname string
Role string
IsDefault bool
}
+type MerchantRoleInput struct {
+ Code string
+ Name string
+ Permissions []string
+}
+
+var merchantRoleCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,63}$`)
+
+func merchantPermissionsText(permissions []string) (string, error) {
+ allowed := map[string]struct{}{
+ model.PermissionProductsManage: {}, model.PermissionOrdersManage: {}, model.PermissionWalletView: {},
+ model.PermissionWalletLedger: {}, model.PermissionRechargeManage: {}, model.PermissionAPIManage: {},
+ model.PermissionCallbacksManage: {}, model.PermissionMembersManage: {},
+ }
+ set := make(map[string]struct{}, len(permissions))
+ for _, permission := range permissions {
+ permission = strings.TrimSpace(permission)
+ if _, ok := allowed[permission]; !ok {
+ return "", errors.New("包含无效的角色权限")
+ }
+ set[permission] = struct{}{}
+ }
+ items := make([]string, 0, len(set))
+ for permission := range set {
+ items = append(items, permission)
+ }
+ sort.Strings(items)
+ return strings.Join(items, ","), nil
+}
+
+func (s *MerchantService) ListRoles(merchantID uint) ([]model.MerchantRole, error) {
+ roles := builtinMerchantRoles(merchantID)
+ var custom []model.MerchantRole
+ if err := s.db.Where("merchant_id = ?", merchantID).Order("id ASC").Find(&custom).Error; err != nil {
+ return nil, err
+ }
+ return append(roles, custom...), nil
+}
+
+func builtinMerchantRoles(merchantID uint) []model.MerchantRole {
+ permissions, _ := (&TenantService{}).MerchantRolePermissions(merchantID, model.MemberRoleOwner)
+ items := make([]string, 0, len(permissions))
+ for permission := range permissions {
+ items = append(items, permission)
+ }
+ sort.Strings(items)
+ return []model.MerchantRole{{MerchantID: merchantID, Code: model.MemberRoleOwner, Name: "负责人", Permissions: strings.Join(items, ","), Status: 1}}
+}
+
+func createDefaultMerchantRoles(tx *gorm.DB, merchantID uint) error {
+ roles := []model.MerchantRole{
+ {MerchantID: merchantID, Code: model.MemberRoleOperator, Name: "运营", Permissions: strings.Join([]string{model.PermissionAPIManage, model.PermissionCallbacksManage, model.PermissionOrdersManage, model.PermissionProductsManage, model.PermissionWalletView}, ","), Status: 1},
+ {MerchantID: merchantID, Code: model.MemberRoleFinance, Name: "财务", Permissions: strings.Join([]string{model.PermissionRechargeManage, model.PermissionWalletLedger, model.PermissionWalletView}, ","), Status: 1},
+ {MerchantID: merchantID, Code: "support", Name: "客服", Permissions: model.PermissionOrdersManage, Status: 1},
+ }
+ return tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "merchant_id"}, {Name: "code"}}, DoNothing: true}).Create(&roles).Error
+}
+
+func (s *MerchantService) CreateRole(merchantID uint, in MerchantRoleInput, actorUserID uint) (*model.MerchantRole, error) {
+ in.Code = strings.ToLower(strings.TrimSpace(in.Code))
+ in.Name = strings.TrimSpace(in.Name)
+ if !merchantRoleCodePattern.MatchString(in.Code) {
+ return nil, errors.New("角色编码需为 2-64 位小写字母、数字或连字符")
+ }
+ if isReservedMerchantRoleCode(in.Code) {
+ return nil, errors.New("角色编码不能使用系统保留角色")
+ }
+ if in.Name == "" {
+ return nil, errors.New("角色名称不能为空")
+ }
+ permissions, err := merchantPermissionsText(in.Permissions)
+ if err != nil {
+ return nil, err
+ }
+ role := &model.MerchantRole{MerchantID: merchantID, Code: in.Code, Name: in.Name, Permissions: permissions, Status: 1}
+ err = s.db.Transaction(func(tx *gorm.DB) error {
+ var merchant model.Merchant
+ if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
+ return errors.New("商户不存在或已禁用")
+ }
+ if err := tx.Create(role).Error; err != nil {
+ return err
+ }
+ return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.role.create", "merchant_role", fmt.Sprint(role.ID), map[string]string{"code": role.Code})
+ })
+ if err != nil {
+ if strings.Contains(err.Error(), "duplicate key") {
+ return nil, errors.New("角色编码已存在")
+ }
+ return nil, err
+ }
+ return role, nil
+}
+
+func isReservedMerchantRoleCode(code string) bool {
+ switch code {
+ case model.MemberRoleOwner, model.MemberRoleViewer, model.MemberRoleOperator, model.MemberRoleFinance, "support":
+ return true
+ default:
+ return false
+ }
+}
+
+func (s *MerchantService) UpdateRole(merchantID, roleID uint, in MerchantRoleInput, actorUserID uint) (*model.MerchantRole, error) {
+ in.Name = strings.TrimSpace(in.Name)
+ if in.Name == "" {
+ return nil, errors.New("角色名称不能为空")
+ }
+ permissions, err := merchantPermissionsText(in.Permissions)
+ if err != nil {
+ return nil, err
+ }
+ var role model.MerchantRole
+ err = s.db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Where("id = ? AND merchant_id = ?", roleID, merchantID).First(&role).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("角色不存在")
+ }
+ return err
+ }
+ if err := tx.Model(&role).Updates(map[string]interface{}{"name": in.Name, "permissions": permissions}).Error; err != nil {
+ return err
+ }
+ return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.role.update", "merchant_role", fmt.Sprint(roleID), map[string]string{"code": role.Code})
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &role, nil
+}
+
func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUserID uint) (*model.MerchantMember, error) {
- if !isValidMemberRole(in.Role) {
- return nil, errors.New("无效的商户成员角色")
+ in.Role = strings.TrimSpace(in.Role)
+ if in.Role == "" {
+ return nil, errors.New("商户成员角色不能为空")
}
- member := &model.MerchantMember{
- MerchantID: merchantID,
- UserID: in.UserID,
- Role: in.Role,
- Status: 1,
- IsDefault: in.IsDefault,
+ if in.UserID == 0 && strings.TrimSpace(in.Username) == "" {
+ return nil, errors.New("请选择已有账号或填写新员工账号")
}
+ member := &model.MerchantMember{MerchantID: merchantID, Role: in.Role, Status: 1, IsDefault: in.IsDefault}
err := s.db.Transaction(func(tx *gorm.DB) error {
var merchant model.Merchant
if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
return errors.New("商户不存在或已禁用")
}
var user model.User
- if err := tx.Where("id = ? AND status = ?", in.UserID, 1).First(&user).Error; err != nil {
- return errors.New("用户不存在或已禁用")
+ createdUser := false
+ if in.UserID != 0 {
+ if err := tx.Where("id = ? AND status = ?", in.UserID, 1).First(&user).Error; err != nil {
+ return errors.New("用户不存在或已禁用")
+ }
+ } else {
+ username := strings.TrimSpace(in.Username)
+ if len(username) < 3 {
+ return errors.New("员工用户名至少 3 位")
+ }
+ if len(in.Password) < 6 {
+ return errors.New("员工密码至少 6 位")
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
+ if err != nil {
+ return err
+ }
+ user = model.User{Username: username, PasswordHash: string(hash), Nickname: fallbackName(in.Nickname, username), Role: model.RoleMerchant, Status: 1}
+ if err := tx.Create(&user).Error; err != nil {
+ return errors.New("员工用户名已存在")
+ }
+ createdUser = true
}
+ if _, err := (&TenantService{db: tx}).MerchantRolePermissions(merchantID, in.Role); err != nil {
+ return err
+ }
+ member.UserID = user.ID
+ // 新建员工仅属于当前商户,登录时应直接进入该商户。
+ member.IsDefault = in.IsDefault || createdUser
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "merchant_id"}, {Name: "user_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
@@ -46,12 +208,12 @@ func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUse
}).Create(member).Error; err != nil {
return err
}
- return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.upsert", "merchant_member", fmt.Sprintf("%d:%d", merchantID, in.UserID), map[string]string{"role": in.Role})
+ return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.upsert", "merchant_member", fmt.Sprintf("%d:%d", merchantID, user.ID), map[string]string{"role": in.Role})
})
if err != nil {
return nil, err
}
- if err := s.db.Where("merchant_id = ? AND user_id = ?", merchantID, in.UserID).First(member).Error; err != nil {
+ if err := s.db.Preload("User").Where("merchant_id = ? AND user_id = ?", merchantID, member.UserID).First(member).Error; err != nil {
return nil, err
}
return member, nil
diff --git a/backend/internal/service/merchant_test.go b/backend/internal/service/merchant_test.go
index 37c27f8..471efc2 100644
--- a/backend/internal/service/merchant_test.go
+++ b/backend/internal/service/merchant_test.go
@@ -35,3 +35,97 @@ func TestCreateAPIClientEnforcesPerMerchantLimit(t *testing.T) {
t.Fatalf("expected per-merchant limit error, got %v", err)
}
}
+
+func TestMerchantCustomRoleIsScopedAndCanCreateEmployee(t *testing.T) {
+ db := newServiceTestDB(t)
+ merchantA := model.Merchant{Code: "team-merchant-a", Name: "团队商户 A", Status: model.MerchantStatusActive}
+ merchantB := model.Merchant{Code: "team-merchant-b", Name: "团队商户 B", Status: model.MerchantStatusActive}
+ actor := model.User{Username: "team-owner", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1}
+ if err := db.Create(&merchantA).Error; err != nil {
+ t.Fatalf("create merchant A: %v", err)
+ }
+ if err := db.Create(&merchantB).Error; err != nil {
+ t.Fatalf("create merchant B: %v", err)
+ }
+ if err := db.Create(&actor).Error; err != nil {
+ t.Fatalf("create actor: %v", err)
+ }
+
+ svc := NewMerchantService(db, nil, NewTenantService(db))
+ role, err := svc.CreateRole(merchantA.ID, MerchantRoleInput{
+ Code: "after-sales",
+ Name: "客服",
+ Permissions: []string{model.PermissionOrdersManage},
+ }, actor.ID)
+ if err != nil {
+ t.Fatalf("create custom role: %v", err)
+ }
+
+ member, err := svc.AddMember(merchantA.ID, AddMemberInput{
+ Username: "merchant-a-support",
+ Password: "password123",
+ Nickname: "客服小李",
+ Role: role.Code,
+ }, actor.ID)
+ if err != nil {
+ t.Fatalf("create employee: %v", err)
+ }
+ if member.User == nil || member.User.Username != "merchant-a-support" || member.Role != "after-sales" {
+ t.Fatalf("unexpected member: %+v", member)
+ }
+ if !member.IsDefault {
+ t.Fatal("new employee should default to the merchant that created it")
+ }
+
+ if _, err := svc.AddMember(merchantB.ID, AddMemberInput{
+ Username: "merchant-b-support",
+ Password: "password123",
+ Role: role.Code,
+ }, actor.ID); err == nil {
+ t.Fatal("expected merchant A role to be rejected for merchant B")
+ }
+}
+
+func TestDefaultMerchantRolesCanBeEdited(t *testing.T) {
+ db := newServiceTestDB(t)
+ merchant := model.Merchant{Code: "default-roles", Name: "默认角色商户", Status: model.MerchantStatusActive}
+ actor := model.User{Username: "default-role-owner", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1}
+ if err := db.Create(&merchant).Error; err != nil {
+ t.Fatalf("create merchant: %v", err)
+ }
+ if err := db.Create(&actor).Error; err != nil {
+ t.Fatalf("create actor: %v", err)
+ }
+ if err := createDefaultMerchantRoles(db, merchant.ID); err != nil {
+ t.Fatalf("create default roles: %v", err)
+ }
+
+ svc := NewMerchantService(db, nil, NewTenantService(db))
+ roles, err := svc.ListRoles(merchant.ID)
+ if err != nil {
+ t.Fatalf("list roles: %v", err)
+ }
+ var finance model.MerchantRole
+ var hasSupport bool
+ for _, role := range roles {
+ if role.Code == model.MemberRoleFinance {
+ finance = role
+ }
+ if role.Code == "support" && role.Name == "客服" {
+ hasSupport = true
+ }
+ }
+ if finance.ID == 0 || !hasSupport {
+ t.Fatalf("expected editable finance and support roles, got %+v", roles)
+ }
+ updated, err := svc.UpdateRole(merchant.ID, finance.ID, MerchantRoleInput{
+ Name: "财务主管",
+ Permissions: []string{model.PermissionWalletView},
+ }, actor.ID)
+ if err != nil {
+ t.Fatalf("update finance role: %v", err)
+ }
+ if updated.Name != "财务主管" || updated.Permissions != model.PermissionWalletView {
+ t.Fatalf("unexpected updated role: %+v", updated)
+ }
+}
diff --git a/backend/internal/service/tenant.go b/backend/internal/service/tenant.go
index 6a2a275..4210f98 100644
--- a/backend/internal/service/tenant.go
+++ b/backend/internal/service/tenant.go
@@ -45,6 +45,27 @@ func (s *TenantService) ResolveMember(userID uint, merchantRef string) (*model.M
return nil, err
}
+// ResolveMerchantForAdmin 允许平台管理员显式进入任一启用商户的后台管理上下文。
+func (s *TenantService) ResolveMerchantForAdmin(merchantRef string) (*model.Merchant, error) {
+ if strings.TrimSpace(merchantRef) == "" {
+ return nil, errors.New("请选择商户")
+ }
+ tx := s.db.Where("status = ?", model.MerchantStatusActive)
+ if id, err := strconv.ParseUint(merchantRef, 10, 64); err == nil {
+ tx = tx.Where("id = ?", uint(id))
+ } else {
+ tx = tx.Where("code = ?", merchantRef)
+ }
+ var merchant model.Merchant
+ if err := tx.First(&merchant).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("商户不存在或已禁用")
+ }
+ return nil, err
+ }
+ return &merchant, nil
+}
+
func (s *TenantService) EnsureSelfMember(userID uint, role string, status int) error {
var merchant model.Merchant
if err := s.db.Where("code = ?", model.MerchantCodeSelfOperated).First(&merchant).Error; err != nil {
@@ -79,13 +100,42 @@ func (s *TenantService) EnsureMember(merchantID, userID uint, role string, statu
func isValidMemberRole(role string) bool {
switch role {
- case model.MemberRoleOwner, model.MemberRoleOperator, model.MemberRoleFinance, model.MemberRoleViewer:
+ case model.MemberRoleOwner, model.MemberRoleViewer:
return true
default:
return false
}
}
+// MerchantRolePermissions 返回保留角色或商户自定义角色的权限集合。
+// 负责人始终拥有该商户的全部后台权限,避免被自定义角色配置锁死。
+func (s *TenantService) MerchantRolePermissions(merchantID uint, role string) (map[string]struct{}, error) {
+ permissions := builtinMerchantRolePermissions(role)
+ if permissions != nil {
+ return permissions, nil
+ }
+ var merchantRole model.MerchantRole
+ if err := s.db.Where("merchant_id = ? AND code = ? AND status = ?", merchantID, role, 1).First(&merchantRole).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("商户角色不存在或已禁用")
+ }
+ return nil, err
+ }
+ return ParseScopes(merchantRole.Permissions), nil
+}
+
+func builtinMerchantRolePermissions(role string) map[string]struct{} {
+ all := func(values ...string) map[string]struct{} { return ParseScopes(strings.Join(values, ",")) }
+ switch role {
+ case model.MemberRoleOwner:
+ return all(model.PermissionMembersManage, model.PermissionProductsManage, model.PermissionOrdersManage, model.PermissionWalletView, model.PermissionWalletLedger, model.PermissionRechargeManage, model.PermissionAPIManage, model.PermissionCallbacksManage)
+ case model.MemberRoleViewer:
+ return map[string]struct{}{}
+ default:
+ return nil
+ }
+}
+
func MemberCanManage(memberRole string) bool {
return memberRole == model.MemberRoleOwner || memberRole == model.MemberRoleOperator
}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 4936824..871e99a 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,5 +1,5 @@
-import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
-import { ConfigProvider, App as AntdApp } from 'antd'
+import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
+import { ConfigProvider, App as AntdApp, Spin } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import { AuthProvider, useAuth } from './store/auth'
import MainLayout from './layouts/MainLayout'
@@ -25,6 +25,35 @@ function AdminRoute({ children }: { children: ReactNode }) {
return <>{children}>
}
+const tabPermissions: Record = {
+ products: 'products:manage',
+ orders: 'orders:manage',
+ wallet: 'wallet:ledger',
+ api: 'api:manage',
+ callbacks: 'callbacks:manage',
+ members: 'members:manage',
+}
+
+function MerchantPermissionRoute({ permission, children }: { permission?: string; children: ReactNode }) {
+ const { isAdmin, merchantLoading, merchantPermissions } = useAuth()
+ if (isAdmin || !permission) return <>{children}>
+ if (merchantLoading) {
+ return
+ }
+ return merchantPermissions.includes(permission) ? <>{children}> :
+}
+
+function MerchantCenterRoute() {
+ const location = useLocation()
+ const requestedTab = new URLSearchParams(location.search).get('tab') || 'products'
+ const tab = requestedTab in tabPermissions ? requestedTab : 'products'
+ return (
+
+
+
+ )
+}
+
function AppRoutes() {
return (
@@ -39,15 +68,15 @@ function AppRoutes() {
}
>
} />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
}
/>
- } />
+ } />
- request.get('/merchant').then((r) => r.data.data as { merchant: Merchant; role: MerchantMember['role'] }),
+ request.get('/merchant').then((r) => r.data.data as { merchant: Merchant; role: string; permissions: string[] }),
products: (params?: Record) =>
request.get('/merchant/products', { params }).then((r) => r.data.data as PageResult),
updateProductStatus: (id: number, status: MerchantProduct['status']) =>
@@ -103,8 +104,13 @@ export const merchantApi = {
request.post('/merchant/callbacks', data).then((r) => r.data.data as CallbackCredential),
members: () =>
request.get('/merchant/members').then((r) => r.data.data as MerchantMember[]),
- addMember: (data: { user_id: number; role: MerchantMember['role']; is_default?: boolean }) =>
+ addMember: (data: { user_id?: number; username?: string; password?: string; nickname?: string; role: string; is_default?: boolean }) =>
request.post('/merchant/members', data).then((r) => r.data.data as MerchantMember),
+ roles: () => request.get('/merchant/roles').then((r) => r.data.data as MerchantRole[]),
+ createRole: (data: { code: string; name: string; permissions: string[] }) =>
+ request.post('/merchant/roles', data).then((r) => r.data.data as MerchantRole),
+ updateRole: (id: number, data: { name: string; permissions: string[] }) =>
+ request.patch(`/merchant/roles/${id}`, data).then((r) => r.data.data as MerchantRole),
rechargeApplications: (params?: Record) =>
request.get('/merchant/recharge/applications', { params }).then((r) => r.data.data as PageResult),
createRechargeApplication: (data: { amount_cny: number; vouchers: string[]; note?: string }) =>
@@ -180,7 +186,7 @@ export const platformApi = {
request.patch(`/platform/merchants/${id}`, data).then((r) => r.data.data),
addMember: (
merchantId: number,
- data: { user_id: number; role: MerchantMember['role']; is_default?: boolean },
+ data: { user_id?: number; username?: string; password?: string; nickname?: string; role: string; is_default?: boolean },
) => request.post(`/platform/merchants/${merchantId}/members`, data).then((r) => r.data.data as MerchantMember),
productCatalog: () =>
request.get('/platform/product-catalog').then((r) => r.data.data as ProductCatalogItem[]),
diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx
index c8769ef..1d5373f 100644
--- a/frontend/src/layouts/MainLayout.tsx
+++ b/frontend/src/layouts/MainLayout.tsx
@@ -36,6 +36,8 @@ interface SidebarChild {
label: string
path?: string
disabled?: boolean
+ permission?: string
+ feature?: string
}
interface SidebarSection {
@@ -112,38 +114,38 @@ const merchantSections: SidebarSection[] = [
key: 'products',
label: '商品管理',
icon: ,
- children: [{ key: 'merchant-products', label: '商品列表', path: '/merchant-products' }],
+ children: [{ key: 'merchant-products', label: '商品列表', path: '/merchant-products', permission: 'products:manage', feature: 'products' }],
},
{
key: 'orders',
label: '订单管理',
icon: ,
- children: [{ key: 'fulfillment-orders', label: '发货订单', path: '/merchant-orders' }],
+ children: [{ key: 'fulfillment-orders', label: '发货订单', path: '/merchant-orders', permission: 'orders:manage', feature: 'orders' }],
},
{
key: 'funds',
label: '积分管理',
icon: ,
children: [
- { key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet' },
- { key: 'merchant-recharge', label: '积分充值', path: '/merchant-recharge' },
- { key: 'merchant-alert-settings', label: '余额告警', path: '/merchant-alert-settings' },
+ { key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet', permission: 'wallet:ledger', feature: 'wallet' },
+ { key: 'merchant-recharge', label: '积分充值', path: '/merchant-recharge', permission: 'wallet:view', feature: 'wallet' },
+ { key: 'merchant-alert-settings', label: '余额告警', path: '/merchant-alert-settings', permission: 'recharge:manage', feature: 'wallet' },
],
},
{
key: 'staff',
label: '员工管理',
icon: ,
- children: [{ key: 'merchant-members', label: '成员管理', path: '/merchant-members' }],
+ children: [{ key: 'merchant-members', label: '成员管理', path: '/merchant-members', permission: 'members:manage' }],
},
{
key: 'open-api',
label: '开放 API',
icon: ,
children: [
- { key: 'api-keys', label: 'API 密钥', path: '/merchant-api-keys' },
- { key: 'api-callbacks', label: '回调订阅', path: '/merchant-callbacks' },
- { key: 'api-docs', label: '对接文档', path: '/open-api' },
+ { key: 'api-keys', label: 'API 密钥', path: '/merchant-api-keys', permission: 'api:manage', feature: 'api' },
+ { key: 'api-callbacks', label: '回调订阅', path: '/merchant-callbacks', permission: 'callbacks:manage', feature: 'callbacks' },
+ { key: 'api-docs', label: '对接文档', path: '/open-api', permission: 'api:manage', feature: 'api' },
],
},
]
@@ -204,12 +206,23 @@ export default function MainLayout() {
const [collapsed, setCollapsed] = useState(() => {
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true'
})
- const { user, logout, isAdmin } = useAuth()
+ const { user, logout, isAdmin, merchant, merchantPermissions } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const isDocsPage = location.pathname.startsWith('/open-api')
- const sections = useMemo(() => (isAdmin ? adminSections : merchantSections), [isAdmin])
+ const sections = useMemo(() => {
+ if (isAdmin) return adminSections
+ const features = new Set((merchant?.features || '').split(/[\s,]+/).filter(Boolean))
+ return merchantSections.flatMap((section) => {
+ if (!section.children) return [section]
+ const children = section.children.filter((child) => (
+ (!child.permission || merchantPermissions.includes(child.permission))
+ && (!child.feature || features.has(child.feature))
+ ))
+ return children.length > 0 ? [{ ...section, children }] : []
+ })
+ }, [isAdmin, merchant?.features, merchantPermissions])
const selectedKey = getSelectedKey(location.pathname, location.search)
const [openKeys, setOpenKeys] = useState(() => loadStoredOpenKeys(sections, selectedKey))
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index b529624..c7fa258 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -103,8 +103,15 @@ function CustomStatusLine({
export default function Dashboard() {
const [stats, setStats] = useState(null)
const [loading, setLoading] = useState(true)
- const { user, isAdmin } = useAuth()
+ const { user, isAdmin, merchantPermissions } = useAuth()
const navigate = useNavigate()
+ const can = (permission: string) => isAdmin || merchantPermissions.includes(permission)
+ const canOrders = can('orders:manage')
+ const canProducts = can('products:manage')
+ const canMembers = can('members:manage')
+ const canWallet = can('wallet:view')
+ const canAPI = can('api:manage')
+ const canCallbacks = can('callbacks:manage')
useEffect(() => {
dashboardApi
@@ -144,12 +151,16 @@ export default function Dashboard() {
欢迎回到 Skin Hub 游戏皮肤分销管理平台。以下是系统的最新业务状态与发货订单追踪数据。
-
-
+ {canOrders && (
+
+ )}
+ {can('wallet:ledger') && (
+
+ )}
{isAdmin && (
<>
- {/* 第一组:核心业务指标 */}
-
- 基础数据指标
-
-
-
- }
- iconTheme=""
- footerText="活跃商品库"
- footerValue="已上线"
- />
-
-
- }
- iconTheme="metric-card-icon--purple"
- footerText="商户状态"
- footerValue="正常运行"
- />
-
-
- }
- iconTheme="metric-card-icon--emerald"
- footerText="团队成员"
- footerValue="活跃"
- />
-
-
+ {(canProducts || canMembers || canOrders) && (
+ <>
+
+ 业务概览
+
+
+ {canProducts && (
+
+ }
+ footerText="活跃商品库"
+ footerValue="已上线"
+ />
+
+ )}
+ {isAdmin && (
+
+ }
+ iconTheme="metric-card-icon--purple"
+ footerText="商户状态"
+ footerValue="正常运行"
+ />
+
+ )}
+ {canMembers && (
+
+ }
+ iconTheme="metric-card-icon--emerald"
+ footerText="团队成员"
+ footerValue="活跃"
+ />
+
+ )}
+ {canOrders && <>
+
-
+
-
+
0 ? '需处理' : '无积压'}
/>
-
+ >}
+
+ >
+ )}
- {/* 第二组:积分与资金监控 */}
-
- 积分变动与账户额度
-
-
-
+ {canWallet && (
+ <>
+
+ 经营与账户
+
+
+ {canOrders && <>
+
-
+
-
+
-
+ >}
+ {canWallet &&
}
iconTheme=""
/>
-
-
+ }
+
+ >
+ )}
- {/* 第三组:订单发货分布与 API 监控 */}
-
-
+ {(canOrders || canAPI || canCallbacks) &&
+ {canOrders &&
-
-
+ }
+ {(canAPI || canCallbacks) &&
-
+ {canAPI &&
}
suffix={`/ ${numberText(stats?.api_client_count)}`}
/>
-
-
+ }
+ {canCallbacks &&
}
/>
-
-
+ }
+ {canCallbacks &&
}
/>
-
-
+ }
+ {canCallbacks &&
}
valueStyle={(stats?.failed_callback_count ?? 0) > 0 ? { color: '#dc2626' } : undefined}
/>
-
+ }
-
-
+ }
+
}
)
}
diff --git a/frontend/src/pages/MerchantCenter.tsx b/frontend/src/pages/MerchantCenter.tsx
index 7714a2b..5fccaad 100644
--- a/frontend/src/pages/MerchantCenter.tsx
+++ b/frontend/src/pages/MerchantCenter.tsx
@@ -23,6 +23,7 @@ import type {
FulfillmentOrder,
Merchant,
MerchantMember,
+ MerchantRole,
MerchantProduct,
PageResult,
WalletAccount,
@@ -33,9 +34,9 @@ import {
defaultCallbackFormValues,
eventsToValue,
featuresToList,
- memberRoleOptions,
productOptionLabel,
resolveEnabledTab,
+ rolePermissionOptions,
roleText,
scopeOptions,
tabFromSearch,
@@ -58,6 +59,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
const routeTab = useMemo(() => fixedTab ?? tabFromSearch(location.search), [fixedTab, location.search])
const [merchant, setMerchant] = useState(null)
const [merchantRole, setMerchantRole] = useState()
+ const [merchantPermissions, setMerchantPermissions] = useState([])
const [activeTab, setActiveTab] = useState(routeTab ?? 'products')
const [products, setProducts] = useState>({ list: [], total: 0, page: 1, size: 10 })
const [orders, setOrders] = useState>({ list: [], total: 0, page: 1, size: 10 })
@@ -67,11 +69,14 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
const [apiClients, setApiClients] = useState([])
const [callback, setCallback] = useState(null)
const [members, setMembers] = useState([])
+ const [roles, setRoles] = useState([])
const [loading, setLoading] = useState(false)
const [apiClientOpen, setApiClientOpen] = useState(false)
const [apiCredential, setApiCredential] = useState(null)
const [callbackCredential, setCallbackCredential] = useState(null)
const [memberOpen, setMemberOpen] = useState(false)
+ const [roleOpen, setRoleOpen] = useState(false)
+ const [editingRole, setEditingRole] = useState(null)
const [manualOrderOpen, setManualOrderOpen] = useState(false)
const [manualOrderSubmitting, setManualOrderSubmitting] = useState(false)
const [manualOrderProducts, setManualOrderProducts] = useState([])
@@ -80,10 +85,15 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
const [apiClientForm] = Form.useForm()
const [callbackForm] = Form.useForm()
const [memberForm] = Form.useForm()
+ const [roleForm] = Form.useForm()
const [manualOrderForm] = Form.useForm()
- const canManage = merchantRole === 'owner' || merchantRole === 'operator'
- const canFinance = merchantRole === 'owner' || merchantRole === 'finance'
+ const hasPermission = useCallback((permission: string) => merchantPermissions.includes(permission), [merchantPermissions])
+ const canManage = hasPermission('products:manage')
+ const canOrderManage = hasPermission('orders:manage')
+ const canAPIManage = hasPermission('api:manage')
+ const canCallbacksManage = hasPermission('callbacks:manage')
+ const canFinance = hasPermission('wallet:ledger')
const enabledFeatures = useMemo(() => new Set(featuresToList(merchant?.features)), [merchant?.features])
const hasFeature = useCallback((feature: string) => !merchant || enabledFeatures.has(feature), [enabledFeatures, merchant])
const manualOrderProductOptions = useMemo(() => manualOrderProducts.map((item) => ({
@@ -136,6 +146,12 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
const data = await merchantApi.current()
setMerchant(data.merchant)
setMerchantRole(data.role)
+ const nextPermissions = [...new Set(data.permissions || [])].sort()
+ setMerchantPermissions((current) => (
+ current.length === nextPermissions.length && current.every((value, index) => value === nextPermissions[index])
+ ? current
+ : nextPermissions
+ ))
return data
}, [])
@@ -152,26 +168,26 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
const loadWallet = useCallback(async (
page = ledger.page,
size = ledger.size,
- role = merchantRole,
+ permissions = merchantPermissions,
filters?: { reference_no?: string; type?: string },
) => {
const walletData = await merchantApi.wallet()
setWallet(walletData)
- if (role === 'owner' || role === 'finance') {
+ if (permissions.includes('wallet:ledger')) {
const ledgerData = await merchantApi.ledger({ page, size, ...filters })
setLedger(ledgerData)
} else {
setLedger({ list: [], total: 0, page, size })
}
- }, [ledger.page, ledger.size, merchantRole])
+ }, [ledger.page, ledger.size, merchantPermissions])
const loadAPIClients = useCallback(async () => {
const clientData = await merchantApi.apiClients()
setApiClients(clientData || [])
}, [])
- const loadCallbacks = useCallback(async (role = merchantRole) => {
- if (role !== 'owner' && role !== 'operator') {
+ const loadCallbacks = useCallback(async (permissions = merchantPermissions) => {
+ if (!permissions.includes('callbacks:manage')) {
setCallback(null)
callbackForm.resetFields()
return
@@ -183,18 +199,20 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
events: eventsToValue(callbackData.events),
status: callbackData.status,
} : defaultCallbackFormValues())
- }, [callbackForm, merchantRole])
+ }, [callbackForm, merchantPermissions])
- const loadMembers = useCallback(async (role = merchantRole) => {
- if (role !== 'owner' && role !== 'operator') {
+ const loadMembers = useCallback(async (permissions = merchantPermissions) => {
+ if (!permissions.includes('members:manage')) {
setMembers([])
+ setRoles([])
return
}
- const memberData = await merchantApi.members()
+ const [memberData, roleData] = await Promise.all([merchantApi.members(), merchantApi.roles()])
setMembers(memberData || [])
- }, [merchantRole])
+ setRoles(roleData || [])
+ }, [merchantPermissions])
- const loadActiveTab = useCallback(async (role = merchantRole, tab = activeTab) => {
+ const loadActiveTab = useCallback(async (permissions = merchantPermissions, tab = activeTab) => {
switch (tab) {
case 'products':
await loadProducts()
@@ -203,19 +221,19 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
await loadOrders()
return
case 'wallet':
- await loadWallet(undefined, undefined, role)
+ await loadWallet(undefined, undefined, permissions)
return
case 'api':
await loadAPIClients()
return
case 'callbacks':
- await loadCallbacks(role)
+ await loadCallbacks(permissions)
return
case 'members':
- await loadMembers(role)
+ await loadMembers(permissions)
return
}
- }, [activeTab, loadAPIClients, loadCallbacks, loadMembers, loadOrders, loadProducts, loadWallet, merchantRole])
+ }, [activeTab, loadAPIClients, loadCallbacks, loadMembers, loadOrders, loadProducts, loadWallet, merchantPermissions])
const loadAll = useCallback(async () => {
setLoading(true)
@@ -225,7 +243,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
if (!fixedTab && nextTab !== activeTab) {
setActiveTab(nextTab)
}
- await loadActiveTab(current.role, nextTab)
+ await loadActiveTab(current.permissions || [], nextTab)
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败')
} finally {
@@ -254,7 +272,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
const handleWalletFilter = async () => {
const values = await walletFilterForm.validateFields()
- loadWallet(1, ledger.size, merchantRole, {
+ loadWallet(1, ledger.size, merchantPermissions, {
reference_no: values.reference_no || undefined,
type: values.type || undefined,
})
@@ -262,16 +280,16 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
const handleWalletFilterReset = () => {
walletFilterForm.resetFields()
- loadWallet(1, ledger.size, merchantRole, {})
+ loadWallet(1, ledger.size, merchantPermissions, {})
}
const handleLoadLedger = useCallback((page: number, size: number) => {
const values = walletFilterForm.getFieldsValue()
- loadWallet(page, size, merchantRole, {
+ loadWallet(page, size, merchantPermissions, {
reference_no: values.reference_no || undefined,
type: values.type || undefined,
})
- }, [loadWallet, merchantRole, walletFilterForm])
+ }, [loadWallet, merchantPermissions, walletFilterForm])
const newManualClientOrderNo = () => {
const suffix = window.crypto?.randomUUID?.().replaceAll('-', '') || `${Date.now()}${Math.random().toString(36).slice(2)}`
@@ -389,9 +407,10 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
const values = await memberForm.validateFields()
try {
await merchantApi.addMember({
- user_id: values.user_id,
+ username: values.username,
+ password: values.password,
+ nickname: values.nickname,
role: values.role,
- is_default: values.is_default,
})
setMemberOpen(false)
message.success('成员已添加')
@@ -401,6 +420,23 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
}
}
+ const submitRole = async () => {
+ const values = await roleForm.validateFields()
+ try {
+ if (editingRole) {
+ await merchantApi.updateRole(editingRole.id, { name: values.name, permissions: values.permissions || [] })
+ } else {
+ await merchantApi.createRole({ ...values, permissions: values.permissions || [] })
+ }
+ setRoleOpen(false)
+ setEditingRole(null)
+ message.success(editingRole ? '角色已更新' : '角色已创建')
+ loadMembers()
+ } catch (e) {
+ message.error(e instanceof Error ? e.message : '创建失败')
+ }
+ }
+
const toggleAPIClient = async (record: ApiClient) => {
try {
await merchantApi.updateApiClientStatus(record.id, record.status === 'active' ? 'disabled' : 'active')
@@ -426,19 +462,39 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
) },
{ key: 'orders', label: '发货订单', disabled: !hasFeature('orders'), children: (
-
+
) },
{ key: 'wallet', label: '钱包', disabled: !hasFeature('wallet'), children: (
) },
{ key: 'api', label: 'API 密钥', disabled: !hasFeature('api'), children: (
- setApiClientOpen(true)} onNavigateDocs={() => navigate('/open-api')} onNavigateDebug={() => navigate('/api-debug')} onToggleClient={toggleAPIClient} onDeleteClient={deleteAPIClient} />
+ setApiClientOpen(true)} onNavigateDocs={() => navigate('/open-api')} onNavigateDebug={() => navigate('/api-debug')} onToggleClient={toggleAPIClient} onDeleteClient={deleteAPIClient} />
) },
{ key: 'callbacks', label: '回调', disabled: !hasFeature('callbacks'), children: (
-
+
) },
{ key: 'members', label: '成员', children: (
- setMemberOpen(true)} />
+ setMemberOpen(true)}
+ onOpenCreateRole={() => {
+ setEditingRole(null)
+ setRoleOpen(true)
+ }}
+ onEditRole={(role) => {
+ setEditingRole(role)
+ roleForm.setFieldsValue({
+ name: role.name,
+ code: role.code,
+ permissions: role.permissions.split(',').filter(Boolean),
+ })
+ setRoleOpen(true)
+ }}
+ />
) },
]
const fixedTabContent = tabItems.find((item) => item.key === fixedTab)?.children
@@ -541,17 +597,28 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
{callbackCredential && }
- setMemberOpen(false)} destroyOnClose>
+ setMemberOpen(false)} destroyOnClose>
-
+
+
-
-
+
+
-
-
+
+
+
+
+
+
+
+ { setRoleOpen(false); setEditingRole(null) }} destroyOnClose>
+
+ {!editingRole && }
+
diff --git a/frontend/src/pages/PlatformMerchants.tsx b/frontend/src/pages/PlatformMerchants.tsx
index 4f0f25b..7dc96a4 100644
--- a/frontend/src/pages/PlatformMerchants.tsx
+++ b/frontend/src/pages/PlatformMerchants.tsx
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
import {
Button,
Checkbox,
@@ -30,6 +31,7 @@ const featureOptions = [
]
export default function PlatformMerchants() {
+ const navigate = useNavigate()
const [data, setData] = useState>({ list: [], total: 0, page: 1, size: 10 })
const [loading, setLoading] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
@@ -272,7 +274,7 @@ export default function PlatformMerchants() {
{
title: '操作',
key: 'action',
- width: 180,
+ width: 240,
fixed: 'right',
render: (_, record) => (
@@ -284,6 +286,16 @@ export default function PlatformMerchants() {
>
分配商品
+
}
+ }}>添加员工
+ }
+
+ role.id || role.code}
+ size="small"
+ pagination={false}
+ dataSource={roles}
+ columns={[
+ { title: '角色', dataIndex: 'name', width: 160, render: (name, role) => {name}{role.code === 'owner' && 固定} },
+ { title: '权限', dataIndex: 'permissions', render: (permissions) => permissions ? permissions.split(',').map((item: string) => {rolePermissionText(item)}) : '-' },
+ { title: '操作', width: 90, render: (_, role) => role.code === 'owner' ? '-' : onEditRole(role)}>编辑 },
+ ]}
+ />
+
)
}
@@ -761,10 +788,6 @@ function ledgerTypeTag(value: string) {
return {item.text}
}
-function memberRoleTag(value: MerchantMember['role']) {
- return {roleText(value)}
-}
-
function scopesTag(value: string) {
const scopes = (value || '').split(',').filter(Boolean)
if (scopes.length === 0) {
diff --git a/frontend/src/pages/merchantCenterUtils.ts b/frontend/src/pages/merchantCenterUtils.ts
index 24ea185..633df02 100644
--- a/frontend/src/pages/merchantCenterUtils.ts
+++ b/frontend/src/pages/merchantCenterUtils.ts
@@ -12,9 +12,24 @@ export const memberRoleOptions = [
{ value: 'owner', label: '负责人' },
{ value: 'operator', label: '运营' },
{ value: 'finance', label: '财务' },
- { value: 'viewer', label: '只读' },
+ { value: 'support', label: '客服' },
]
+export const rolePermissionOptions = [
+ { value: 'products:manage', label: '商品管理' },
+ { value: 'orders:manage', label: '订单与发货管理' },
+ { value: 'wallet:view', label: '查看钱包与充值记录' },
+ { value: 'wallet:ledger', label: '查看积分明细' },
+ { value: 'recharge:manage', label: '申请充值与管理余额告警' },
+ { value: 'api:manage', label: '管理 API 密钥' },
+ { value: 'callbacks:manage', label: '管理回调' },
+ { value: 'members:manage', label: '管理团队成员与角色' },
+]
+
+export function rolePermissionText(permission: string) {
+ return rolePermissionOptions.find((item) => item.value === permission)?.label || permission
+}
+
export const apiClientMax = 5
export const scopeOptions = [
diff --git a/frontend/src/store/auth.tsx b/frontend/src/store/auth.tsx
index b393e64..17aa8b8 100644
--- a/frontend/src/store/auth.tsx
+++ b/frontend/src/store/auth.tsx
@@ -2,12 +2,13 @@ import {
createContext,
useCallback,
useContext,
+ useEffect,
useMemo,
useState,
type ReactNode,
} from 'react'
-import type { User } from '../types'
-import { authApi } from '../api'
+import type { Merchant, User } from '../types'
+import { authApi, merchantApi } from '../api'
interface AuthState {
token: string | null
@@ -15,6 +16,11 @@ interface AuthState {
login: (username: string, password: string) => Promise
logout: () => void
refreshProfile: () => Promise
+ merchant: Merchant | null
+ merchantRole: string | null
+ merchantPermissions: string[]
+ merchantLoading: boolean
+ refreshMerchantContext: () => Promise
isAdmin: boolean
}
@@ -32,20 +38,63 @@ function loadUser(): User | null {
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState(() => localStorage.getItem('token'))
const [user, setUser] = useState(() => loadUser())
+ const [merchant, setMerchant] = useState(null)
+ const [merchantRole, setMerchantRole] = useState(null)
+ const [merchantPermissions, setMerchantPermissions] = useState([])
+ const [merchantLoading, setMerchantLoading] = useState(() => Boolean(localStorage.getItem('token')))
+
+ const refreshMerchantContext = useCallback(async () => {
+ if (!token) {
+ setMerchant(null)
+ setMerchantRole(null)
+ setMerchantPermissions([])
+ setMerchantLoading(false)
+ return
+ }
+ setMerchantLoading(true)
+ try {
+ const data = await merchantApi.current()
+ setMerchant(data.merchant)
+ setMerchantRole(data.role)
+ const nextPermissions = [...new Set(data.permissions || [])].sort()
+ setMerchantPermissions((current) => (
+ current.length === nextPermissions.length && current.every((value, index) => value === nextPermissions[index])
+ ? current
+ : nextPermissions
+ ))
+ } catch {
+ setMerchant(null)
+ setMerchantRole(null)
+ setMerchantPermissions([])
+ } finally {
+ setMerchantLoading(false)
+ }
+ }, [token])
+
+ useEffect(() => {
+ void refreshMerchantContext()
+ }, [refreshMerchantContext])
const login = useCallback(async (username: string, password: string) => {
const result = await authApi.login(username, password)
+ localStorage.removeItem('merchant_id')
localStorage.setItem('token', result.token)
localStorage.setItem('user', JSON.stringify(result.user))
setToken(result.token)
setUser(result.user)
+ setMerchantLoading(true)
}, [])
const logout = useCallback(() => {
localStorage.removeItem('token')
localStorage.removeItem('user')
+ localStorage.removeItem('merchant_id')
setToken(null)
setUser(null)
+ setMerchant(null)
+ setMerchantRole(null)
+ setMerchantPermissions([])
+ setMerchantLoading(false)
}, [])
const refreshProfile = useCallback(async () => {
@@ -61,9 +110,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
login,
logout,
refreshProfile,
+ merchant,
+ merchantRole,
+ merchantPermissions,
+ merchantLoading,
+ refreshMerchantContext,
isAdmin: user?.role === 'admin',
}),
- [token, user, login, logout, refreshProfile],
+ [token, user, login, logout, refreshProfile, merchant, merchantRole, merchantPermissions, merchantLoading, refreshMerchantContext],
)
return {children}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 28b38c0..6b1aea7 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -25,7 +25,7 @@ export interface MerchantMember {
id: number
merchant_id: number
user_id: number
- role: 'owner' | 'operator' | 'finance' | 'viewer'
+ role: string
status: number
is_default: boolean
user?: User
@@ -33,6 +33,16 @@ export interface MerchantMember {
created_at: string
}
+export interface MerchantRole {
+ id: number
+ merchant_id: number
+ code: string
+ name: string
+ permissions: string
+ status: number
+ created_at: string
+}
+
export interface Product {
id: number
code: string