From 08884270b7bf2a70a8492dc1667093b3a498efc9 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Mon, 9 Nov 2026 18:18:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E5=AE=A2=E6=9C=8D=E8=A7=92=E8=89=B2=E4=B8=8E=E5=AE=A2=E6=9C=8D?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/rock.go | 33 ++ backend/internal/handler/user.go | 5 +- backend/internal/middleware/auth.go | 15 + backend/internal/model/merchant.go | 1 + backend/internal/model/models.go | 5 +- backend/internal/router/router.go | 14 +- backend/internal/router/router_test.go | 45 +++ .../internal/service/platform_user_test.go | 108 +++++++ backend/internal/service/rock_stock.go | 68 +++-- backend/internal/service/rock_support.go | 64 ++++ backend/internal/service/rock_support_test.go | 187 ++++++++++++ backend/internal/service/user.go | 92 +++++- frontend/src/App.tsx | 24 +- frontend/src/api/index.ts | 33 +- frontend/src/layouts/MainLayout.tsx | 36 ++- frontend/src/pages/MerchantCenter.tsx | 6 +- frontend/src/pages/PlatformUsers.tsx | 61 +++- frontend/src/pages/SupportRockTokens.tsx | 286 ++++++++++++++++++ .../platformRock/TokenStatusLogDrawer.tsx | 10 +- .../src/pages/platformRock/tokenColumns.tsx | 84 +++-- frontend/src/store/auth.tsx | 1 + frontend/src/store/authContext.ts | 1 + frontend/src/types/index.ts | 2 +- 23 files changed, 1075 insertions(+), 106 deletions(-) create mode 100644 backend/internal/service/platform_user_test.go create mode 100644 backend/internal/service/rock_support.go create mode 100644 backend/internal/service/rock_support_test.go create mode 100644 frontend/src/pages/SupportRockTokens.tsx diff --git a/backend/internal/handler/rock.go b/backend/internal/handler/rock.go index 84be5e6..e428a37 100644 --- a/backend/internal/handler/rock.go +++ b/backend/internal/handler/rock.go @@ -325,6 +325,39 @@ func (h *RockHandler) ListDeletedTokens(c *gin.Context) { }) } +// ListSupportBoundTokens 平台客服只读查询绑定中的 Token;search 可选,为空时返回全部。 +func (h *RockHandler) ListSupportBoundTokens(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + search := strings.TrimSpace(c.Query("search")) + tokens, total, err := h.rockSvc.ListSupportBoundTokens(c.Request.Context(), page, pageSize, search) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{ + "list": tokens, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +// ListSupportTokenStatusLogs 平台客服只读查询绑定中 Token 的状态历史。 +func (h *RockHandler) ListSupportTokenStatusLogs(c *gin.Context) { + idUint, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "无效的 Token ID") + return + } + logs, err := h.rockSvc.ListSupportTokenStatusLogs(c.Request.Context(), uint(idUint)) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"list": logs}) +} + type restoreTokensReq struct { IDs []uint `json:"ids" binding:"required"` } diff --git a/backend/internal/handler/user.go b/backend/internal/handler/user.go index 203b733..6a340a5 100644 --- a/backend/internal/handler/user.go +++ b/backend/internal/handler/user.go @@ -44,6 +44,7 @@ type createPlatformAdminReq struct { Username string `json:"username" binding:"required"` Password string `json:"password" binding:"required,min=6"` Nickname string `json:"nickname"` + Role string `json:"role"` } func (h *UserHandler) CreatePlatformAdmin(c *gin.Context) { @@ -52,7 +53,7 @@ func (h *UserHandler) CreatePlatformAdmin(c *gin.Context) { response.BadRequest(c, "参数错误") return } - user, err := h.svc.CreatePlatformAdmin(req.Username, req.Password, req.Nickname) + user, err := h.svc.CreatePlatformUser(req.Username, req.Password, req.Nickname, req.Role) if err != nil { response.BadRequest(c, err.Error()) return @@ -65,6 +66,7 @@ type updatePlatformAdminReq struct { Nickname string `json:"nickname"` Password string `json:"password" binding:"omitempty,min=6"` Status int `json:"status"` + Role string `json:"role"` } func (h *UserHandler) UpdatePlatformAdmin(c *gin.Context) { @@ -79,6 +81,7 @@ func (h *UserHandler) UpdatePlatformAdmin(c *gin.Context) { Nickname: req.Nickname, Password: req.Password, Status: req.Status, + Role: req.Role, }, middleware.GetUserID(c)) if err != nil { response.BadRequest(c, err.Error()) diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 6619e53..4d41cde 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -94,6 +94,21 @@ func Tenant(tenantSvc *service.TenantService) gin.HandlerFunc { c.Next() return } + if GetRole(c) == model.RolePlatformSupport { + // 平台客服只借用自营商户的订单上下文:可完整操作发货订单, + // 但不写入 merchant_members,也不会获得商品、钱包、成员等权限。 + merchant, err := tenantSvc.ResolveMerchantForAdmin(model.MerchantCodeSelfOperated) + if err != nil { + response.Forbidden(c, err.Error()) + c.Abort() + return + } + c.Set(CtxMerchantID, merchant.ID) + c.Set(CtxMerchantRole, model.MemberRoleSupport) + c.Set(CtxMerchantPermissions, map[string]struct{}{model.PermissionOrdersManage: {}}) + c.Next() + return + } member, err := tenantSvc.ResolveMember(GetUserID(c), merchantRef) if err != nil { response.Forbidden(c, err.Error()) diff --git a/backend/internal/model/merchant.go b/backend/internal/model/merchant.go index e019214..a4e66c8 100644 --- a/backend/internal/model/merchant.go +++ b/backend/internal/model/merchant.go @@ -24,6 +24,7 @@ const ( MemberRoleOperator = "operator" MemberRoleFinance = "finance" MemberRoleViewer = "viewer" + MemberRoleSupport = "support" PermissionMembersManage = "members:manage" PermissionProductsManage = "products:manage" diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 7e3d40f..74fac59 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -8,8 +8,9 @@ import ( // 用户角色 const ( - RoleAdmin = "admin" // 平台管理员 - RoleMerchant = "merchant" // 商户账号 + RoleAdmin = "admin" // 平台管理员 + RoleMerchant = "merchant" // 商户账号 + RolePlatformSupport = "platform_support" // 平台客服 ) // User 系统用户 diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index c53b540..665c5e4 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -302,7 +302,6 @@ func Setup(h *Handlers) *gin.Engine { platform.GET("/platform/rock/tokens/:id/status-logs", h.Rock.ListTokenStatusLogs) platform.POST("/platform/rock/tokens/:id/refresh-binding", h.Rock.RefreshBinding) platform.POST("/platform/rock/token-batches/refresh-stale", h.Rock.RefreshStaleTokens) - platform.GET("/platform/rock/tokens/:id/package-link", h.Rock.TokenPackageLink) platform.GET("/platform/rock/token-batches/preview", h.Rock.PreviewTokenDelete) platform.POST("/platform/rock/token-batches/batch-delete", h.Rock.BatchDeleteTokens) platform.POST("/platform/rock/token-batches/delete-all", h.Rock.DeleteAllTokens) @@ -313,6 +312,19 @@ func Setup(h *Handlers) *gin.Engine { } } + // 平台客服查询:仅平台管理员与平台客服可用,跨商户只读查询绑定中 Token。 + // package-link 是只读的上游定位链接,允许平台客服复用。 + if h.Rock != nil { + supportQuery := api.Group("") + supportQuery.Use(middleware.Auth(h.JWT)) + supportQuery.Use(middleware.RequireRole(model.RoleAdmin, model.RolePlatformSupport)) + { + supportQuery.GET("/platform/rock/support/bound-tokens", h.Rock.ListSupportBoundTokens) + supportQuery.GET("/platform/rock/support/tokens/:id/status-logs", h.Rock.ListSupportTokenStatusLogs) + supportQuery.GET("/platform/rock/tokens/:id/package-link", h.Rock.TokenPackageLink) + } + } + auth := api.Group("") auth.Use(middleware.Auth(h.JWT)) auth.Use(middleware.Tenant(h.Tenant)) diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go index 6e6f995..d8599eb 100644 --- a/backend/internal/router/router_test.go +++ b/backend/internal/router/router_test.go @@ -36,6 +36,7 @@ func newTestRouter(t *testing.T) (*gin.Engine, *jwt.Manager) { merchantSvc := service.NewMerchantService(db, codec, tenantSvc) rechargeSvc := service.NewRechargeService(db, fulfillmentSvc, "") deliverySvc := service.NewDeliveryService(fulfillmentSvc, "", "", "", "", 0) + rockSvc := service.NewRockService(db, service.NewRockClient("https://example.com")) engine := Setup(&Handlers{ Auth: handler.NewAuthHandler(authSvc), @@ -45,6 +46,7 @@ func newTestRouter(t *testing.T) (*gin.Engine, *jwt.Manager) { SourceOpen: handler.NewOpenHandler(fulfillmentSvc), Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc, deliverySvc, rechargeSvc), Recharge: handler.NewRechargeHandler(rechargeSvc), + Rock: handler.NewRockHandler(rockSvc), JWT: jm, Tenant: tenantSvc, OpenDB: db, @@ -240,3 +242,46 @@ func TestPlatformMerchantMembersRoutes(t *testing.T) { t.Fatalf("平台管理员查询商户角色应返回 200,实际 %d: %s", w.Code, w.Body.String()) } } + +// TestPlatformSupportContext 验证平台客服只获得自营商户订单上下文与客服查询权限。 +func TestPlatformSupportContext(t *testing.T) { + engine, jm := newTestRouter(t) + token, err := jm.Generate(99, "platform-support", model.RolePlatformSupport) + if err != nil { + t.Fatalf("generate token: %v", err) + } + + cases := []struct { + name string + path string + want int + }{ + {name: "商户上下文", path: "/api/merchant", want: 200}, + {name: "发货订单", path: "/api/merchant/orders", want: 200}, + {name: "商品列表应被拒绝", path: "/api/merchant/products", want: 403}, + {name: "钱包应被拒绝", path: "/api/merchant/wallet", want: 403}, + {name: "平台管理接口应被拒绝", path: "/api/platform/merchants", want: 403}, + {name: "客服查询空条件应被拦截", path: "/api/platform/rock/support/bound-tokens", want: 400}, + {name: "客服查询", path: "/api/platform/rock/support/bound-tokens?search=SUP", want: 200}, + {name: "上游包链接路由", path: "/api/platform/rock/tokens/1/package-link", want: 400}, + {name: "商户客服旧接口应下线", path: "/api/merchant/support/rock/bound-tokens", want: 404}, + } + for _, tc := range cases { + req := httptest.NewRequest("GET", tc.path, nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != tc.want { + t.Fatalf("%s: 期望 %d,实际 %d: %s", tc.name, tc.want, w.Code, w.Body.String()) + } + } + + req := httptest.NewRequest("GET", "/api/merchant", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + body := w.Body.String() + if !strings.Contains(body, `"role":"support"`) || !strings.Contains(body, `"orders:manage"`) { + t.Fatalf("平台客服应获得自营商户客服上下文与 orders:manage,实际: %s", body) + } +} diff --git a/backend/internal/service/platform_user_test.go b/backend/internal/service/platform_user_test.go new file mode 100644 index 0000000..25d1c4a --- /dev/null +++ b/backend/internal/service/platform_user_test.go @@ -0,0 +1,108 @@ +package service + +import ( + "testing" + + "affiliate_dash/internal/model" +) + +func TestPlatformSupportAccountRoleSwitch(t *testing.T) { + db := newServiceTestDB(t) + svc := NewUserService(db, NewTenantService(db)) + + actor, err := svc.CreatePlatformAdmin("platform-role-actor", "password123", "管理员") + if err != nil { + t.Fatalf("创建管理员失败: %v", err) + } + support, err := svc.CreatePlatformUser("platform-role-support", "password123", "平台客服", model.RolePlatformSupport) + if err != nil { + t.Fatalf("创建平台客服失败: %v", err) + } + if support.Role != model.RolePlatformSupport { + t.Fatalf("平台客服角色不符: %+v", support) + } + + var membershipCount int64 + if err := db.Model(&model.MerchantMember{}).Where("user_id = ?", support.ID).Count(&membershipCount).Error; err != nil { + t.Fatalf("统计成员关系失败: %v", err) + } + if membershipCount != 0 { + t.Fatalf("平台客服不应写入商户成员关系,实际 %d 条", membershipCount) + } + + list, total, err := svc.ListPlatformAdmins(1, 100) + if err != nil { + t.Fatalf("查询平台账号失败: %v", err) + } + if total < 2 { + t.Fatalf("平台账号列表应包含管理员与平台客服,实际 total=%d", total) + } + foundSupport := false + for _, item := range list { + if item.ID == support.ID && item.Role == model.RolePlatformSupport { + foundSupport = true + } + } + if !foundSupport { + t.Fatalf("平台账号列表未包含平台客服: %+v", list) + } + + // 平台客服 -> 平台管理员:补自营商户负责人上下文。 + updated, err := svc.UpdatePlatformAdmin(support.ID, UpdatePlatformAdminInput{ + Username: support.Username, + Nickname: support.Nickname, + Status: 1, + Role: model.RoleAdmin, + }, actor.ID) + if err != nil { + t.Fatalf("提升为管理员失败: %v", err) + } + if updated.Role != model.RoleAdmin { + t.Fatalf("角色未更新为管理员: %+v", updated) + } + var membership model.MerchantMember + if err := db.Where("user_id = ?", support.ID).First(&membership).Error; err != nil { + t.Fatalf("管理员缺少自营商户成员关系: %v", err) + } + if membership.Role != model.MemberRoleOwner || !membership.IsDefault { + t.Fatalf("管理员成员关系不符: %+v", membership) + } + + // 平台管理员 -> 平台客服:移除商户成员关系,回到 Tenant 合成上下文。 + updated, err = svc.UpdatePlatformAdmin(support.ID, UpdatePlatformAdminInput{ + Username: support.Username, + Nickname: support.Nickname, + Status: 1, + Role: model.RolePlatformSupport, + }, actor.ID) + if err != nil { + t.Fatalf("降级为平台客服失败: %v", err) + } + if updated.Role != model.RolePlatformSupport { + t.Fatalf("角色未更新为平台客服: %+v", updated) + } + if err := db.Model(&model.MerchantMember{}).Where("user_id = ?", support.ID).Count(&membershipCount).Error; err != nil { + t.Fatalf("统计成员关系失败: %v", err) + } + if membershipCount != 0 { + t.Fatalf("降级后应移除商户成员关系,实际 %d 条", membershipCount) + } +} + +func TestCannotDemoteLastPlatformAdmin(t *testing.T) { + db := newServiceTestDB(t) + svc := NewUserService(db, nil) + + admin, err := svc.CreatePlatformAdmin("only-role-admin", "password123", "唯一管理员") + if err != nil { + t.Fatalf("创建管理员失败: %v", err) + } + if _, err := svc.UpdatePlatformAdmin(admin.ID, UpdatePlatformAdminInput{ + Username: admin.Username, + Nickname: admin.Nickname, + Status: 1, + Role: model.RolePlatformSupport, + }, 0); err == nil { + t.Fatal("最后一个启用的平台管理员不能被降级") + } +} diff --git a/backend/internal/service/rock_stock.go b/backend/internal/service/rock_stock.go index 13cc87f..129183a 100644 --- a/backend/internal/service/rock_stock.go +++ b/backend/internal/service/rock_stock.go @@ -523,6 +523,42 @@ func (s *RockService) CheckStockAndAutoPurchase(ctx context.Context) { log.Printf("[RockService] 自动补货成功,实际入库 %d 个 Token", len(tokens)) } +// prepareRockTokenList 填充列表展示字段:提取码与关联订单的商户单号。 +func (s *RockService) prepareRockTokenList(ctx context.Context, tokens []model.RockToken) { + for i := range tokens { + tokens[i].TokenCode = tokens[i].Token + } + + // 批量反查关联订单的商户单号(创建订单时的 ClientOrderNo) + orderNos := make([]string, 0, len(tokens)) + for _, t := range tokens { + if t.AssignedOrderNo != "" { + orderNos = append(orderNos, t.AssignedOrderNo) + } + } + if len(orderNos) == 0 { + return + } + var orders []struct { + OrderNo string `gorm:"column:order_no"` + ClientOrderNo string `gorm:"column:client_order_no"` + } + if scanErr := s.db.WithContext(ctx).Table("fulfillment_orders"). + Select("order_no, client_order_no"). + Where("order_no IN ?", orderNos). + Scan(&orders).Error; scanErr == nil { + clientMap := make(map[string]string, len(orders)) + for _, o := range orders { + clientMap[o.OrderNo] = o.ClientOrderNo + } + for i := range tokens { + if cNo, ok := clientMap[tokens[i].AssignedOrderNo]; ok && cNo != "" { + tokens[i].ClientOrderNo = cNo + } + } + } +} + // ListTokens 分页查询 Token 列表 func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status, search string) ([]model.RockToken, int64, error) { if page <= 0 { @@ -553,37 +589,7 @@ func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status if err != nil { return nil, 0, err } - for i := range tokens { - tokens[i].TokenCode = tokens[i].Token - } - - // 批量反查关联订单的商户单号(创建订单时的 ClientOrderNo) - orderNos := make([]string, 0, len(tokens)) - for _, t := range tokens { - if t.AssignedOrderNo != "" { - orderNos = append(orderNos, t.AssignedOrderNo) - } - } - if len(orderNos) > 0 { - var orders []struct { - OrderNo string `gorm:"column:order_no"` - ClientOrderNo string `gorm:"column:client_order_no"` - } - if scanErr := s.db.WithContext(ctx).Table("fulfillment_orders"). - Select("order_no, client_order_no"). - Where("order_no IN ?", orderNos). - Scan(&orders).Error; scanErr == nil { - clientMap := make(map[string]string, len(orders)) - for _, o := range orders { - clientMap[o.OrderNo] = o.ClientOrderNo - } - for i := range tokens { - if cNo, ok := clientMap[tokens[i].AssignedOrderNo]; ok && cNo != "" { - tokens[i].ClientOrderNo = cNo - } - } - } - } + s.prepareRockTokenList(ctx, tokens) return tokens, total, nil } diff --git a/backend/internal/service/rock_support.go b/backend/internal/service/rock_support.go new file mode 100644 index 0000000..ee3c26d --- /dev/null +++ b/backend/internal/service/rock_support.go @@ -0,0 +1,64 @@ +package service + +import ( + "context" + "errors" + "strings" + + "affiliate_dash/internal/model" +) + +const rockSupportSearchRequired = "请输入包ID、上游编号、提取码、兑换码、工单号、商户单号或角色 ID 后再查询" + +// ListSupportBoundTokens 平台客服只读查询绑定中的 Token。 +// 固定只返回 status=bound;search 必填,避免客服页面在未输入条件时拉取全量数据。 +func (s *RockService) ListSupportBoundTokens(ctx context.Context, page, pageSize int, search string) ([]model.RockToken, int64, error) { + search = strings.TrimSpace(search) + if search == "" { + return nil, 0, errors.New(rockSupportSearchRequired) + } + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + + db := s.db.WithContext(ctx).Model(&model.RockToken{}).Where("status = ?", model.RockStatusBound) + term := "%" + search + "%" + db = db.Where( + "upstream_token_id = ? OR upstream_token_id LIKE ? OR redeem_code LIKE ? OR assigned_order_no LIKE ? OR expected_uid LIKE ? OR actual_role_name LIKE ? OR actual_role_id LIKE ? OR remark LIKE ? OR CAST(package_id AS TEXT) LIKE ? OR token LIKE ? OR CAST(id AS TEXT) = ? OR assigned_order_no IN (SELECT order_no FROM fulfillment_orders WHERE client_order_no LIKE ?)", + search, term, term, term, term, term, term, term, term, term, search, term, + ) + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var tokens []model.RockToken + if err := db.Order("id DESC"). + Offset((page - 1) * pageSize). + Limit(pageSize). + Find(&tokens).Error; err != nil { + return nil, 0, err + } + s.prepareRockTokenList(ctx, tokens) + return tokens, total, nil +} + +// ListSupportTokenStatusLogs 平台客服只读查询状态历史;只允许查看绑定中的 Token。 +func (s *RockService) ListSupportTokenStatusLogs(ctx context.Context, tokenID uint) ([]model.RockTokenStatusLog, error) { + var count int64 + if err := s.db.WithContext(ctx).Model(&model.RockToken{}). + Where("id = ? AND status = ?", tokenID, model.RockStatusBound). + Count(&count).Error; err != nil { + return nil, err + } + if count == 0 { + return nil, errors.New("Token 不存在或无权查看") + } + return s.ListTokenStatusLogs(ctx, tokenID) +} diff --git a/backend/internal/service/rock_support_test.go b/backend/internal/service/rock_support_test.go new file mode 100644 index 0000000..5e43419 --- /dev/null +++ b/backend/internal/service/rock_support_test.go @@ -0,0 +1,187 @@ +package service + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "affiliate_dash/internal/model" +) + +func createSupportTestOrder(t *testing.T, svc *RockService, merchantID uint, orderNo, clientOrderNo string) { + t.Helper() + now := time.Now() + order := model.FulfillmentOrder{ + MerchantID: merchantID, + OrderNo: orderNo, + ClientOrderNo: clientOrderNo, + ProductSKU: model.RockSKUBlackTshirt, + ProductName: "黑色ROCK长袖T恤", + OrderStatus: model.OrderStatusPaid, + OrderSource: "manual", + Currency: "POINT", + CreatedAt: now, + UpdatedAt: now, + } + if err := svc.db.Create(&order).Error; err != nil { + t.Fatalf("创建订单失败: %v", err) + } +} + +func createSupportTestToken(t *testing.T, svc *RockService, upstreamID, redeemCode, orderNo, status, roleName, roleID string) model.RockToken { + t.Helper() + now := time.Now() + token := model.RockToken{ + UpstreamTokenID: upstreamID, + Token: "jwt-" + upstreamID, + RedeemCode: redeemCode, + Color: model.RockColorBlack, + Status: status, + AssignedOrderNo: orderNo, + ExpectedUID: roleID, + ActualRoleName: roleName, + ActualRoleID: roleID, + ActualChannel: "安卓-微信", + BoundAt: &now, + } + if err := svc.db.Create(&token).Error; err != nil { + t.Fatalf("创建 Token 失败: %v", err) + } + return token +} + +func TestSupportBoundTokens_RequiresSearchAndFiltersBound(t *testing.T) { + db := newRockTestDB(t) + svc := NewRockService(db, NewRockClient("https://example.com")) + ctx := context.Background() + + var selfMerchant model.Merchant + if err := db.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil { + t.Fatalf("读取自营商户失败: %v", err) + } + otherMerchant := model.Merchant{Code: "support-test-other", Name: "测试商户", Status: model.MerchantStatusActive} + if err := db.Create(&otherMerchant).Error; err != nil { + t.Fatalf("创建测试商户失败: %v", err) + } + + createSupportTestOrder(t, svc, selfMerchant.ID, "SUP-ORDER-1", "SUP-CLIENT-1") + createSupportTestOrder(t, svc, otherMerchant.ID, "SUP-ORDER-2", "SUP-CLIENT-2") + + selfToken1 := createSupportTestToken(t, svc, "SUP-TOKEN-1", "SUP-CODE-1", "SUP-ORDER-1", model.RockStatusBound, "客服测试角色", "10001") + selfToken2 := createSupportTestToken(t, svc, "SUP-TOKEN-2", "SUP-CODE-2", "SUP-ORDER-1", model.RockStatusBound, "客服第二个角色", "10002") + otherToken := createSupportTestToken(t, svc, "SUP-TOKEN-3", "SUP-CODE-3", "SUP-ORDER-2", model.RockStatusBound, "其他商户角色", "20001") + usingToken := createSupportTestToken(t, svc, "SUP-TOKEN-4", "SUP-CODE-4", "SUP-ORDER-1", model.RockStatusUsing, "使用中角色", "10003") + + // 不传搜索条件时必须拒绝,避免一次拉取全量数据。 + if _, _, err := svc.ListSupportBoundTokens(ctx, 1, 20, " "); err == nil || !strings.Contains(err.Error(), "请输入") { + t.Fatalf("空搜索条件必须拒绝,实际: %v", err) + } + + // 输入搜索条件后返回全部命中,且不混入 using。 + allTokens, allTotal, err := svc.ListSupportBoundTokens(ctx, 1, 20, "SUP-") + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if allTotal != 3 || len(allTokens) != 3 { + t.Fatalf("应返回 3 条绑定中 Token,实际 total=%d len=%d", allTotal, len(allTokens)) + } + for _, token := range allTokens { + if token.Status != model.RockStatusBound { + t.Fatalf("查询混入非 bound 状态: %+v", token) + } + if token.TokenCode == "" { + t.Fatalf("TokenCode 未填充: %+v", token) + } + if token.ID == usingToken.ID { + t.Fatalf("using Token 不应出现在查询结果中: %+v", token) + } + } + + // 按商户单号查询:同一订单下的两条 Token 都应命中。 + byClientOrder, clientTotal, err := svc.ListSupportBoundTokens(ctx, 1, 20, "SUP-CLIENT-1") + if err != nil { + t.Fatalf("按商户单号查询失败: %v", err) + } + if clientTotal != 2 || len(byClientOrder) != 2 { + t.Fatalf("按商户单号应查到 2 条,实际 total=%d len=%d", clientTotal, len(byClientOrder)) + } + for _, token := range byClientOrder { + if token.ID != selfToken1.ID && token.ID != selfToken2.ID { + t.Fatalf("按商户单号查询结果不符: %+v", token) + } + } + + // 按上游编号查询:只命中其他商户那条。 + byUpstream, upstreamTotal, err := svc.ListSupportBoundTokens(ctx, 1, 20, "SUP-TOKEN-3") + if err != nil { + t.Fatalf("按上游编号查询失败: %v", err) + } + if upstreamTotal != 1 || len(byUpstream) != 1 || byUpstream[0].ID != otherToken.ID { + t.Fatalf("按上游编号查询结果不符: total=%d tokens=%+v", upstreamTotal, byUpstream) + } + + // 按 Token ID 查询。 + byID, idTotal, err := svc.ListSupportBoundTokens(ctx, 1, 20, fmt.Sprint(otherToken.ID)) + if err != nil { + t.Fatalf("按 Token ID 查询失败: %v", err) + } + if idTotal != 1 || len(byID) != 1 || byID[0].ID != otherToken.ID { + t.Fatalf("按 Token ID 查询结果不符: total=%d tokens=%+v", idTotal, byID) + } + + // 使用中的 Token 不能被客服查询到。 + usingResult, usingTotal, err := svc.ListSupportBoundTokens(ctx, 1, 20, "SUP-TOKEN-4") + if err != nil { + t.Fatalf("查询 using Token 失败: %v", err) + } + if usingTotal != 0 || len(usingResult) != 0 { + t.Fatalf("using Token 不应被查询到,实际 total=%d", usingTotal) + } +} + +func TestSupportTokenStatusLogs_RequiresBound(t *testing.T) { + db := newRockTestDB(t) + svc := NewRockService(db, NewRockClient("https://example.com")) + ctx := context.Background() + + var selfMerchant model.Merchant + if err := db.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil { + t.Fatalf("读取自营商户失败: %v", err) + } + createSupportTestOrder(t, svc, selfMerchant.ID, "SUP-LOG-ORDER-1", "SUP-LOG-CLIENT-1") + + boundToken := createSupportTestToken(t, svc, "SUP-LOG-TOKEN-1", "SUP-LOG-CODE-1", "SUP-LOG-ORDER-1", model.RockStatusBound, "自营客服角色", "30001") + usingToken := createSupportTestToken(t, svc, "SUP-LOG-TOKEN-2", "SUP-LOG-CODE-2", "SUP-LOG-ORDER-1", model.RockStatusUsing, "使用中角色", "30002") + + for _, token := range []model.RockToken{boundToken, usingToken} { + entry := model.RockTokenStatusLog{ + TokenID: token.ID, + UpstreamTokenID: token.UpstreamTokenID, + EventType: rockTokenEventStatusChanged, + FromStatus: model.RockStatusUsing, + ToStatus: token.Status, + Source: rockTokenSourceBindCheck, + Reason: "测试状态变更", + } + if err := db.Create(&entry).Error; err != nil { + t.Fatalf("创建状态日志失败: %v", err) + } + } + + logs, err := svc.ListSupportTokenStatusLogs(ctx, boundToken.ID) + if err != nil { + t.Fatalf("读取绑定中 Token 历史失败: %v", err) + } + if len(logs) != 1 { + t.Fatalf("绑定中 Token 应读到 1 条历史,实际 %d", len(logs)) + } + + if _, err := svc.ListSupportTokenStatusLogs(ctx, usingToken.ID); err == nil || !strings.Contains(err.Error(), "不存在") { + t.Fatalf("非绑定中 Token 必须拒绝,实际: %v", err) + } + if _, err := svc.ListSupportTokenStatusLogs(ctx, 999999); err == nil || !strings.Contains(err.Error(), "不存在") { + t.Fatalf("不存在的 Token 必须拒绝,实际: %v", err) + } +} diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index b6f2838..2cbc1e6 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -28,6 +28,7 @@ type UserListQuery struct { Size int Keyword string Role string + Roles []string Status *int } @@ -55,7 +56,9 @@ func (s *UserService) List(q UserListQuery) ([]model.User, int64, error) { like := "%" + q.Keyword + "%" tx = tx.Where("users.username LIKE ? OR users.nickname LIKE ?", like, like) } - if q.Role != "" { + if len(q.Roles) > 0 { + tx = tx.Where("users.role IN ?", q.Roles) + } else if q.Role != "" { tx = tx.Where("users.role = ?", q.Role) } if q.Status != nil { @@ -79,7 +82,7 @@ func (s *UserService) Create(username, password, nickname, role string, merchant if role == "" { role = model.RoleMerchant } - if role != model.RoleAdmin && role != model.RoleMerchant { + if role != model.RoleAdmin && role != model.RoleMerchant && role != model.RolePlatformSupport { return nil, errors.New("无效的账号角色") } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) @@ -142,10 +145,15 @@ type UpdatePlatformAdminInput struct { Nickname string Password string Status int + Role string } func (s *UserService) ListPlatformAdmins(page, size int) ([]model.User, int64, error) { - list, total, err := s.List(UserListQuery{Page: page, Size: size, Role: model.RoleAdmin}) + list, total, err := s.List(UserListQuery{ + Page: page, + Size: size, + Roles: []string{model.RoleAdmin, model.RolePlatformSupport}, + }) if err != nil { return nil, 0, err } @@ -213,10 +221,23 @@ func (s *UserService) ListMerchantAccountGroups(page, size int) ([]MerchantMembe return groups, total, nil } +// CreatePlatformUser creates a platform-level account. Platform admins get the +// hidden self-operated owner membership; platform support accounts use the +// synthetic tenant context in middleware.Tenant and do not become merchant members. +func (s *UserService) CreatePlatformUser(username, password, nickname, role string) (*model.User, error) { + if role == "" { + role = model.RoleAdmin + } + if role != model.RoleAdmin && role != model.RolePlatformSupport { + return nil, errors.New("无效的平台账号角色") + } + return s.Create(username, password, nickname, role, 0) +} + // CreatePlatformAdmin creates an account with platform privileges and the // hidden self-operated merchant context required by merchant-side routes. func (s *UserService) CreatePlatformAdmin(username, password, nickname string) (*model.User, error) { - return s.Create(username, password, nickname, model.RoleAdmin, 0) + return s.CreatePlatformUser(username, password, nickname, model.RoleAdmin) } func (s *UserService) UpdatePlatformAdmin(id uint, in UpdatePlatformAdminInput, actorUserID uint) (*model.User, error) { @@ -238,10 +259,18 @@ func (s *UserService) UpdatePlatformAdmin(id uint, in UpdatePlatformAdminInput, } return err } - if user.Role != model.RoleAdmin { - return errors.New("只能管理平台管理员账号") + if user.Role != model.RoleAdmin && user.Role != model.RolePlatformSupport { + return errors.New("只能管理平台账号") } - if user.Status == 1 && in.Status == 0 { + targetRole := user.Role + if in.Role != "" { + targetRole = in.Role + } + if targetRole != model.RoleAdmin && targetRole != model.RolePlatformSupport { + return errors.New("无效的平台账号角色") + } + // 降级或停用最后一个启用的平台管理员会导致平台无法登录。 + if user.Role == model.RoleAdmin && user.Status == 1 && (targetRole != model.RoleAdmin || in.Status == 0) { var admins int64 if err := tx.Model(&model.User{}).Where("role = ? AND status = ?", model.RoleAdmin, 1).Count(&admins).Error; err != nil { return err @@ -259,7 +288,40 @@ func (s *UserService) UpdatePlatformAdmin(id uint, in UpdatePlatformAdminInput, return errors.New("用户名已存在") } } - updates := map[string]interface{}{"username": in.Username, "nickname": in.Nickname, "status": in.Status} + if targetRole != user.Role { + // 管理员需要自营商户负责人成员关系;平台客服使用 Tenant 合成上下文, + // 不写入 merchant_members,避免出现在商户成员列表里。 + if err := tx.Where("user_id = ?", user.ID).Delete(&model.MerchantMember{}).Error; err != nil { + return err + } + if targetRole == model.RoleAdmin { + var selfMerchant model.Merchant + if err := tx.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil { + return err + } + member := model.MerchantMember{ + MerchantID: selfMerchant.ID, + UserID: user.ID, + Role: model.MemberRoleOwner, + Status: 1, + IsDefault: true, + } + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "merchant_id"}, {Name: "user_id"}}, + DoUpdates: clause.Assignments(map[string]interface{}{ + "role": model.MemberRoleOwner, "status": 1, "is_default": true, + }), + }).Create(&member).Error; err != nil { + return err + } + } + } + updates := map[string]interface{}{ + "username": in.Username, + "nickname": in.Nickname, + "status": in.Status, + "role": targetRole, + } if in.Password != "" { hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost) if err != nil { @@ -303,8 +365,9 @@ func (s *UserService) UpdateStatus(id uint, status int) error { }) } -// DeletePlatformAdmin removes a platform-only account. It protects the current -// account and the final enabled platform administrator so the platform cannot be locked out. +// DeletePlatformAdmin removes a platform account (admin or support). It protects +// the current account and the final enabled platform administrator so the platform +// cannot be locked out. func (s *UserService) DeletePlatformAdmin(id, actorUserID uint) error { if id == 0 { return errors.New("用户不存在") @@ -320,8 +383,8 @@ func (s *UserService) DeletePlatformAdmin(id, actorUserID uint) error { } return err } - if user.Role != model.RoleAdmin { - return errors.New("只能删除平台管理员账号") + if user.Role != model.RoleAdmin && user.Role != model.RolePlatformSupport { + return errors.New("只能删除平台账号") } if user.Role == model.RoleAdmin && user.Status == 1 { var admins int64 @@ -332,6 +395,9 @@ func (s *UserService) DeletePlatformAdmin(id, actorUserID uint) error { return errors.New("至少保留一个启用的平台管理员") } } + if err := tx.Where("user_id = ?", user.ID).Delete(&model.MerchantMember{}).Error; err != nil { + return err + } return tx.Delete(&user).Error }) } @@ -371,7 +437,7 @@ func (s *UserService) DeleteMerchantAccount(id, actorUserID uint) error { return err } if user.Role != model.RoleMerchant { - return errors.New("平台管理员请在平台管理员列表中管理") + return errors.New("平台账号请在平台账号列表中管理") } if err := tx.Where("user_id = ?", user.ID).Delete(&model.MerchantMember{}).Error; err != nil { return err diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4312bbf..eaef41f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ const PlatformOrders = lazy(() => import('./pages/PlatformOrders')) const PlatformProducts = lazy(() => import('./pages/PlatformProducts')) const PlatformUsers = lazy(() => import('./pages/PlatformUsers')) const PlatformRock = lazy(() => import('./pages/PlatformRock')) +const SupportRockTokens = lazy(() => import('./pages/SupportRockTokens')) // RouteFallback 是懒加载页面与权限校验的统一占位,避免切换时白屏。 function RouteFallback() { @@ -59,8 +60,21 @@ function MerchantPermissionRoute({ permission, children }: { permission?: string return merchantPermissions.includes(permission) ? <>{children} : } +function SupportQueryRoute({ children }: { children: ReactNode }) { + const { isAdmin, isPlatformSupport } = useAuth() + return isAdmin || isPlatformSupport ? <>{children} : +} + +function HomeRoute() { + const { isPlatformSupport } = useAuth() + if (isPlatformSupport) return + return +} + function MerchantCenterRoute() { + const { isPlatformSupport } = useAuth() const location = useLocation() + if (isPlatformSupport) return const requestedTab = new URLSearchParams(location.search).get('tab') || 'products' const tab = requestedTab in tabPermissions ? requestedTab : 'products' return ( @@ -83,7 +97,7 @@ function AppRoutes() { } > - } /> + } /> } /> } /> } /> @@ -125,6 +139,14 @@ function AppRoutes() { } /> + + + + } + /> } /> } /> r.data.data as PageResult), - create: (data: { username: string; password: string; nickname?: string }) => - request.post("/platform/admins", data).then((r) => r.data.data as User), + create: (data: { + username: string + password: string + nickname?: string + role?: 'admin' | 'platform_support' + }) => request.post("/platform/admins", data).then((r) => r.data.data as User), update: ( id: number, data: { - username: string; - nickname?: string; - password?: string; - status: number; + username: string + nickname?: string + password?: string + status: number + role?: 'admin' | 'platform_support' }, ) => request @@ -893,6 +898,22 @@ export const rockApi = { request .post("/platform/rock/token-batches/refresh-stale") .then((r) => r.data.data as RockStaleRefreshResult), + listSupportBoundTokens: (params: { page?: number; page_size?: number; search: string }) => + request + .get("/platform/rock/support/bound-tokens", { params }) + .then( + (r) => + r.data.data as { + list: RockTokenItem[]; + total: number; + page: number; + page_size: number; + }, + ), + listSupportTokenStatusLogs: (id: number) => + request + .get(`/platform/rock/support/tokens/${id}/status-logs`) + .then((r) => r.data.data as { list: RockTokenStatusLog[] }), previewTokenDelete: (params?: { status?: string; search?: string }) => request .get("/platform/rock/token-batches/preview", { params }) diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index f58da0a..ba3e88d 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -70,6 +70,7 @@ const adminSections: SidebarSection[] = [ { key: 'merchant-products', label: '商品列表', path: '/merchant-products' }, { key: 'platform-products', label: '渠道与路由配置', path: '/platform-products' }, { key: 'platform-rock', label: 'Rock发货与库存风控', path: '/platform-rock' }, + { key: 'support-rock', label: '客服查询', path: '/support-rock-tokens' }, ], }, { @@ -122,6 +123,21 @@ const adminSections: SidebarSection[] = [ }, ] +const platformSupportSections: SidebarSection[] = [ + { + key: 'products', + label: '商品管理', + icon: , + children: [{ key: 'support-rock', label: '客服查询', path: '/support-rock-tokens' }], + }, + { + key: 'orders', + label: '订单管理', + icon: , + children: [{ key: 'fulfillment-orders', label: '发货订单', path: '/merchant-orders' }], + }, +] + const merchantSections: SidebarSection[] = [ { key: 'dashboard', @@ -174,6 +190,7 @@ function getSelectedKey(pathname: string, search: string) { if (pathname.startsWith('/merchant-products')) return 'merchant-products' if (pathname.startsWith('/platform-products')) return 'platform-products' if (pathname.startsWith('/platform-rock')) return 'platform-rock' + if (pathname.startsWith('/support-rock-tokens')) return 'support-rock' if (pathname.startsWith('/merchant-orders')) return 'fulfillment-orders' if (pathname.startsWith('/merchant-wallet')) return 'merchant-wallet' if (pathname.startsWith('/merchant-recharge')) return 'merchant-recharge' @@ -228,13 +245,13 @@ export default function MainLayout() { const [collapsed, setCollapsed] = useState(() => { return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true' }) - const { user, logout, refreshProfile, isAdmin, merchant, merchantPermissions } = useAuth() + const { user, logout, refreshProfile, isAdmin, isPlatformSupport, merchant, merchantPermissions } = useAuth() useEffect(() => { - // 管理员始终保持平台全局视角,清除任何残留的商户上下文 - if (isAdmin && localStorage.getItem('merchant_id')) { + // 平台角色保持全局视角,清除任何残留的商户上下文 + if ((isAdmin || isPlatformSupport) && localStorage.getItem('merchant_id')) { localStorage.removeItem('merchant_id') } - }, [isAdmin]) + }, [isAdmin, isPlatformSupport]) const [passwordOpen, setPasswordOpen] = useState(false) const [passwordSaving, setPasswordSaving] = useState(false) const [passwordForm] = Form.useForm() @@ -244,6 +261,7 @@ export default function MainLayout() { const sections = useMemo(() => { if (isAdmin) return adminSections + if (isPlatformSupport) return platformSupportSections const features = new Set((merchant?.features || '').split(/[\s,]+/).filter(Boolean)) return merchantSections.flatMap((section) => { if (!section.children) return [section] @@ -253,7 +271,7 @@ export default function MainLayout() { )) return children.length > 0 ? [{ ...section, children }] : [] }) - }, [isAdmin, merchant?.features, merchantPermissions]) + }, [isAdmin, isPlatformSupport, merchant?.features, merchantPermissions]) const selectedKey = getSelectedKey(location.pathname, location.search) const [openKeys, setOpenKeys] = useState(() => loadStoredOpenKeys(sections, selectedKey)) @@ -324,7 +342,9 @@ export default function MainLayout() { label: (
{user?.nickname || user?.username}
-
角色:{isAdmin ? '平台超级管理员' : '商户成员'}
+
+ 角色:{isAdmin ? '平台超级管理员' : isPlatformSupport ? '平台客服' : '商户成员'} +
), }, @@ -459,6 +479,10 @@ export default function MainLayout() { 管理员 + ) : isPlatformSupport ? ( + + 平台客服 + ) : ( 商户 diff --git a/frontend/src/pages/MerchantCenter.tsx b/frontend/src/pages/MerchantCenter.tsx index 45c28ad..41aa9f5 100644 --- a/frontend/src/pages/MerchantCenter.tsx +++ b/frontend/src/pages/MerchantCenter.tsx @@ -60,7 +60,7 @@ export default function MerchantCenter({ fixedTab, title = "商户中心", }: MerchantCenterProps = {}) { - const { isAdmin, user } = useAuth(); + const { isAdmin, isPlatformSupport, user } = useAuth(); const location = useLocation(); const navigate = useNavigate(); const routeTab = useMemo( @@ -893,7 +893,9 @@ export default function MerchantCenter({ title={title} subtitle={ merchant - ? `${merchant.name} (${merchant.code}) · 当前身份: ${merchantRole ? roleText(merchantRole) : "-"}` + ? isPlatformSupport + ? `${merchant.name} (${merchant.code}) · 平台客服` + : `${merchant.name} (${merchant.code}) · 当前身份: ${merchantRole ? roleText(merchantRole) : "-"}` : "商户配置与管理中心" } breadcrumbs={computedBreadcrumbs} diff --git a/frontend/src/pages/PlatformUsers.tsx b/frontend/src/pages/PlatformUsers.tsx index 11a3ce0..4b3e71f 100644 --- a/frontend/src/pages/PlatformUsers.tsx +++ b/frontend/src/pages/PlatformUsers.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { Button, Form, Input, Modal, Popconfirm, Space, Switch, Table, Tabs, Tag, Typography, message } from 'antd' +import { Button, Form, Input, Modal, Popconfirm, Select, Space, Switch, Table, Tabs, Tag, Typography, message } from 'antd' import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons' import type { ColumnsType } from 'antd/es/table' import { platformApi, userApi } from '../api' @@ -15,6 +15,16 @@ type AdminFormValues = { nickname?: string password?: string status: boolean + role: 'admin' | 'platform_support' +} + +const platformRoleOptions = [ + { value: 'admin', label: '平台管理员' }, + { value: 'platform_support', label: '平台客服' }, +] + +function platformRoleLabel(role: string) { + return platformRoleOptions.find((item) => item.value === role)?.label || role } function maskPhone(value?: string) { @@ -69,13 +79,19 @@ export default function PlatformUsers() { const openCreate = () => { setEditingAdmin(null) - form.setFieldsValue({ username: '', nickname: '', password: '', status: true }) + form.setFieldsValue({ username: '', nickname: '', password: '', status: true, role: 'admin' }) setModalOpen(true) } const openEdit = useCallback((record: User) => { setEditingAdmin(record) - form.setFieldsValue({ username: record.username, nickname: record.nickname, password: '', status: record.status === 1 }) + form.setFieldsValue({ + username: record.username, + nickname: record.nickname, + password: '', + status: record.status === 1, + role: record.role === 'platform_support' ? 'platform_support' : 'admin', + }) setModalOpen(true) }, [form]) @@ -89,11 +105,17 @@ export default function PlatformUsers() { nickname: values.nickname || '', password: values.password || undefined, status: values.status ? 1 : 0, + role: values.role, }) - message.success('平台管理员已更新') + message.success('平台账号已更新,角色变更需该账号重新登录生效') } else { - await userApi.create({ username: values.username, password: values.password || '', nickname: values.nickname || '' }) - message.success('平台管理员已创建') + await userApi.create({ + username: values.username, + password: values.password || '', + nickname: values.nickname || '', + role: values.role, + }) + message.success('平台账号已创建') } setModalOpen(false) form.resetFields() @@ -108,7 +130,7 @@ export default function PlatformUsers() { const remove = useCallback(async (record: User) => { try { await userApi.remove(record.id) - message.success('平台管理员已删除') + message.success('平台账号已删除') void loadAdmins() } catch (e) { message.error(e instanceof Error ? e.message : '删除失败') @@ -206,6 +228,16 @@ export default function PlatformUsers() { const columns: ColumnsType = useMemo(() => [ { title: '用户名', dataIndex: 'username', width: 220, render: (value) => {value} }, { title: '昵称', dataIndex: 'nickname', width: 180, render: (value) => value || '-' }, + { + title: '角色', + dataIndex: 'role', + width: 120, + render: (role) => ( + + {platformRoleLabel(role)} + + ), + }, { title: '手机号', width: 170, render: (_, record) => phoneCell(record) }, { title: '绑定设备', width: 200, render: (_, record) => deviceCell(record) }, { title: '状态', dataIndex: 'status', width: 100, render: (value) => value === 1 ? 启用 : 停用 }, @@ -216,7 +248,7 @@ export default function PlatformUsers() { render: (_, record) => record.id === currentUser?.id ? 当前账号 : ( - remove(record)} okButtonProps={{ danger: true }}> + remove(record)} okButtonProps={{ danger: true }}> @@ -281,7 +313,7 @@ export default function PlatformUsers() { const items = useMemo(() => [ { key: 'admins', - label: `平台管理员 (${admins.total})`, + label: `平台账号 (${admins.total})`, children: {tab === 'admins' && }} + extra={{tab === 'admins' && }} /> setModalOpen(false)} onOk={() => void submit()} @@ -325,13 +357,16 @@ export default function PlatformUsers() { okText="保存" destroyOnHidden > -
+ + + handleSearchChange(e.target.value)} + onPressEnter={handleSearch} + placeholder="包ID / 上游编号 / 提取码 / 兑换码 / 工单号 / 商户单号 / 角色 ID / 角色名" + style={{ flex: '1 1 360px', minWidth: 0 }} + /> + + + + {!searched ? ( +
+ +
+ 为避免一次展示过多数据,本页面不会默认加载绑定中的 Token 列表。 +
+
+ ) : ( +
`共 ${count} 条绑定中 Token`, + onChange: handlePageChange, + }} + /> + )} + + setStatusLogToken(null)} + loadLogs={loadStatusLogs} + /> + + setPreviewVoucherOpen(false)} + /> + + ) +} diff --git a/frontend/src/pages/platformRock/TokenStatusLogDrawer.tsx b/frontend/src/pages/platformRock/TokenStatusLogDrawer.tsx index cd5e9c3..e37ab97 100644 --- a/frontend/src/pages/platformRock/TokenStatusLogDrawer.tsx +++ b/frontend/src/pages/platformRock/TokenStatusLogDrawer.tsx @@ -11,6 +11,7 @@ export interface TokenStatusLogDrawerProps { open: boolean token: RockTokenItem | null onClose: () => void + loadLogs?: (tokenId: number) => Promise } const EVENT_META: Record = { @@ -66,6 +67,7 @@ export function TokenStatusLogDrawer({ open, token, onClose, + loadLogs, }: TokenStatusLogDrawerProps) { const screens = Grid.useBreakpoint() const isMobile = !screens.md @@ -78,14 +80,16 @@ export function TokenStatusLogDrawer({ setLoading(true) setError('') try { - const res = await rockApi.listTokenStatusLogs(token.id) - setLogs(res.list || []) + const list = loadLogs + ? await loadLogs(token.id) + : (await rockApi.listTokenStatusLogs(token.id)).list || [] + setLogs(list) } catch (e) { setError(e instanceof Error ? e.message : '加载状态历史失败') } finally { setLoading(false) } - }, [token]) + }, [token, loadLogs]) useEffect(() => { if (!open || !token) return diff --git a/frontend/src/pages/platformRock/tokenColumns.tsx b/frontend/src/pages/platformRock/tokenColumns.tsx index 3a9ffc1..9449d52 100644 --- a/frontend/src/pages/platformRock/tokenColumns.tsx +++ b/frontend/src/pages/platformRock/tokenColumns.tsx @@ -5,6 +5,7 @@ import { EyeOutlined, FileTextOutlined, HistoryOutlined, + MessageOutlined, StopOutlined, SyncOutlined, UndoOutlined, @@ -80,6 +81,7 @@ export interface TokenColumnHandlers { usingWarnDays: number usingDangerDays: number boundWarnDays: number + readOnly?: boolean refreshingTokenId: number | null onRefreshBinding: (id: number) => void openingUpstreamId: number | null @@ -100,6 +102,7 @@ export function buildTokenColumns({ usingWarnDays, usingDangerDays, boundWarnDays, + readOnly, refreshingTokenId, onRefreshBinding, openingUpstreamId, @@ -362,14 +365,31 @@ export function buildTokenColumns({ title: '备注', width: 44, align: 'center', - render: (_, record) => ( - - ), + render: (_, record) => + readOnly ? ( + record.remark ? ( + + {record.remark} + + } + > + + + + + ) : ( + - + ) + ) : ( + + ), }, { title: '凭证', @@ -448,24 +468,28 @@ export function buildTokenColumns({ ) }, }, - { - title: '原始', - width: 44, - align: 'center', - render: (_, record) => - record.raw_data ? ( - - ) + if (readOnly) { + return ( + + {upstreamButton} + {historyButton} + + ) + } if (record.status === 'mismatch') { return ( diff --git a/frontend/src/store/auth.tsx b/frontend/src/store/auth.tsx index 3b8c416..7f60c17 100644 --- a/frontend/src/store/auth.tsx +++ b/frontend/src/store/auth.tsx @@ -104,6 +104,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { merchantLoading, refreshMerchantContext, isAdmin: user?.role === 'admin', + isPlatformSupport: user?.role === 'platform_support', }), [token, user, login, applyLoginResult, logout, refreshProfile, merchant, merchantRole, merchantPermissions, merchantLoading, refreshMerchantContext], ) diff --git a/frontend/src/store/authContext.ts b/frontend/src/store/authContext.ts index a6c3bbc..f6945ee 100644 --- a/frontend/src/store/authContext.ts +++ b/frontend/src/store/authContext.ts @@ -14,6 +14,7 @@ export interface AuthState { merchantLoading: boolean refreshMerchantContext: () => Promise isAdmin: boolean + isPlatformSupport: boolean } // Context 与 useAuth 单独放在本文件:auth.tsx 因此只导出 AuthProvider 组件, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 835b5d1..6ee3900 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -2,7 +2,7 @@ export interface User { id: number username: string nickname: string - role: 'admin' | 'merchant' + role: 'admin' | 'merchant' | 'platform_support' status: number created_at: string phone?: string