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>
- - + + - - + + + + + {!editingRole && } +