feat: 删除平台客服角色,客服查询并入发货订单 Tab

This commit is contained in:
2026-11-10 10:28:48 +08:00
parent eba3af2054
commit 162745082b
18 changed files with 195 additions and 268 deletions
+1 -4
View File
@@ -44,7 +44,6 @@ 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) {
@@ -53,7 +52,7 @@ func (h *UserHandler) CreatePlatformAdmin(c *gin.Context) {
response.BadRequest(c, "参数错误")
return
}
user, err := h.svc.CreatePlatformUser(req.Username, req.Password, req.Nickname, req.Role)
user, err := h.svc.CreatePlatformUser(req.Username, req.Password, req.Nickname)
if err != nil {
response.BadRequest(c, err.Error())
return
@@ -66,7 +65,6 @@ 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) {
@@ -81,7 +79,6 @@ 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())
+27 -15
View File
@@ -16,6 +16,7 @@ const (
CtxUsername = "username"
CtxRole = "role"
CtxMerchantRole = "merchant_role"
CtxMerchantCode = "merchant_code"
CtxMerchantPermissions = "merchant_permissions"
)
@@ -88,27 +89,13 @@ func Tenant(tenantSvc *service.TenantService) gin.HandlerFunc {
return
}
c.Set(CtxMerchantID, merchant.ID)
c.Set(CtxMerchantCode, merchant.Code)
c.Set(CtxMerchantRole, model.MemberRoleOwner)
permissions, _ := tenantSvc.MerchantRolePermissions(merchant.ID, model.MemberRoleOwner)
c.Set(CtxMerchantPermissions, permissions)
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())
@@ -116,6 +103,9 @@ func Tenant(tenantSvc *service.TenantService) gin.HandlerFunc {
return
}
c.Set(CtxMerchantID, member.MerchantID)
if member.Merchant != nil {
c.Set(CtxMerchantCode, member.Merchant.Code)
}
c.Set(CtxMerchantRole, member.Role)
permissions, err := tenantSvc.MerchantRolePermissions(member.MerchantID, member.Role)
if err != nil {
@@ -172,3 +162,25 @@ func GetMerchantRole(c *gin.Context) string {
role, _ := value.(string)
return role
}
func GetMerchantCode(c *gin.Context) string {
value, _ := c.Get(CtxMerchantCode)
code, _ := value.(string)
return code
}
// RequireSelfOrderSiteOrAdmin 允许平台管理员,或「自用自动发货」商户的成员访问客服查询。
func RequireSelfOrderSiteOrAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
if GetRole(c) == model.RoleAdmin {
c.Next()
return
}
if GetMerchantCode(c) == model.MerchantCodeSelfOrderSite {
c.Next()
return
}
response.Forbidden(c, "权限不足")
c.Abort()
}
}
+2
View File
@@ -12,6 +12,8 @@ const (
// MerchantCodeSelfOperated 是平台自营商户编码,作为默认商品目录的模板来源。
MerchantCodeSelfOperated = "self-operated"
// MerchantCodeSelfOrderSite 是「自用自动发货」商户编码,客服查询仅对该商户开放。
MerchantCodeSelfOrderSite = "self-order-site"
MerchantFeatureProducts = "products"
MerchantFeatureOrders = "orders"
+2 -3
View File
@@ -8,9 +8,8 @@ import (
// 用户角色
const (
RoleAdmin = "admin" // 平台管理员
RoleMerchant = "merchant" // 商户账号
RolePlatformSupport = "platform_support" // 平台客服
RoleAdmin = "admin" // 平台管理员
RoleMerchant = "merchant" // 商户账号
)
// User 系统用户
+5 -3
View File
@@ -312,12 +312,14 @@ func Setup(h *Handlers) *gin.Engine {
}
}
// 平台客服查询:仅平台管理员与平台客服可用,跨商户只读查询绑定中 Token。
// package-link 是只读的上游定位链接,允许平台客服复用。
// 客服查询:平台管理员与「自用自动发货」商户可用,跨商户只读查询绑定中 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.Use(middleware.Tenant(h.Tenant))
supportQuery.Use(middleware.RequireSelfOrderSiteOrAdmin())
supportQuery.Use(middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage))
{
supportQuery.GET("/platform/rock/support/bound-tokens", h.Rock.ListSupportBoundTokens)
supportQuery.GET("/platform/rock/support/tokens/:id/status-logs", h.Rock.ListSupportTokenStatusLogs)
+59 -23
View File
@@ -16,10 +16,18 @@ import (
"affiliate_dash/internal/testdb"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// newTestRouter 构建一个接好全部依赖的测试路由。
func newTestRouter(t *testing.T) (*gin.Engine, *jwt.Manager) {
t.Helper()
engine, jm, _ := newTestRouterWithDB(t)
return engine, jm
}
// newTestRouterWithDB 在测试路由之外一并返回数据库,便于构造多商户权限场景。
func newTestRouterWithDB(t *testing.T) (*gin.Engine, *jwt.Manager, *gorm.DB) {
t.Helper()
gin.SetMode(gin.TestMode)
db := testdb.New(t)
@@ -55,7 +63,7 @@ func newTestRouter(t *testing.T) (*gin.Engine, *jwt.Manager) {
OpenAPISecret: "source-secret",
OpenSignSkew: 300,
})
return engine, jm
return engine, jm, db
}
func TestSetupDoesNotPanic(t *testing.T) {
@@ -243,31 +251,60 @@ func TestPlatformMerchantMembersRoutes(t *testing.T) {
}
}
// 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)
// TestSelfOrderSiteSupportQueryAccess 验证客服查询只对平台管理员与「自用自动发货」商户开放。
func TestSelfOrderSiteSupportQueryAccess(t *testing.T) {
engine, jm, db := newTestRouterWithDB(t)
selfSite := model.Merchant{Code: model.MerchantCodeSelfOrderSite, Name: "自用自动发货", Status: model.MerchantStatusActive}
if err := db.Create(&selfSite).Error; err != nil {
t.Fatalf("创建自用自动发货商户失败: %v", err)
}
otherSite := model.Merchant{Code: "other-site", Name: "其他商户", Status: model.MerchantStatusActive}
if err := db.Create(&otherSite).Error; err != nil {
t.Fatalf("创建其他商户失败: %v", err)
}
createUser := func(username, role string) model.User {
user := model.User{Username: username, PasswordHash: "x", Nickname: username, Role: role, Status: 1}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("创建账号 %s 失败: %v", username, err)
}
return user
}
bind := func(user model.User, merchant model.Merchant) {
if err := db.Create(&model.MerchantMember{
MerchantID: merchant.ID, UserID: user.ID, Role: model.MemberRoleOwner, Status: 1, IsDefault: true,
}).Error; err != nil {
t.Fatalf("绑定商户成员失败: %v", err)
}
}
var selfOperated model.Merchant
if err := db.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfOperated).Error; err != nil {
t.Fatalf("读取自营商户失败: %v", err)
}
admin := createUser("support-admin", model.RoleAdmin)
bind(admin, selfOperated)
selfStaff := createUser("self-site-staff", model.RoleMerchant)
bind(selfStaff, selfSite)
otherStaff := createUser("other-site-staff", model.RoleMerchant)
bind(otherStaff, otherSite)
cases := []struct {
name string
path string
user model.User
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},
{name: "平台管理员", user: admin, want: 200},
{name: "自用自动发货商户", user: selfStaff, want: 200},
{name: "其他商户", user: otherStaff, want: 403},
}
for _, tc := range cases {
req := httptest.NewRequest("GET", tc.path, nil)
token, err := jm.Generate(tc.user.ID, tc.user.Username, tc.user.Role)
if err != nil {
t.Fatalf("generate token: %v", err)
}
req := httptest.NewRequest("GET", "/api/platform/rock/support/bound-tokens?search=SUP", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
@@ -276,12 +313,11 @@ func TestPlatformSupportContext(t *testing.T) {
}
}
req := httptest.NewRequest("GET", "/api/merchant", nil)
req.Header.Set("Authorization", "Bearer "+token)
// 未登录不能访问客服查询。
req := httptest.NewRequest("GET", "/api/platform/rock/support/bound-tokens?search=SUP", nil)
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)
if w.Code != 401 {
t.Fatalf("未登录应返回 401,实际 %d: %s", w.Code, w.Body.String())
}
}
+33 -62
View File
@@ -6,103 +6,74 @@ import (
"affiliate_dash/internal/model"
)
func TestPlatformSupportAccountRoleSwitch(t *testing.T) {
func TestPlatformAdminAccountLifecycle(t *testing.T) {
db := newServiceTestDB(t)
svc := NewUserService(db, NewTenantService(db))
actor, err := svc.CreatePlatformAdmin("platform-role-actor", "password123", "管理员")
admin, err := svc.CreatePlatformUser("platform-admin-1", "password123", "管理员一")
if err != nil {
t.Fatalf("创建管理员失败: %v", err)
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)
if admin.Role != model.RoleAdmin {
t.Fatalf("平台账号角色应为 admin,实际 %s", admin.Role)
}
var membershipCount int64
if err := db.Model(&model.MerchantMember{}).Where("user_id = ?", support.ID).Count(&membershipCount).Error; err != nil {
t.Fatalf("统计成员关系失败: %v", err)
var membership model.MerchantMember
if err := db.Where("user_id = ?", admin.ID).First(&membership).Error; err != nil {
t.Fatalf("平台管理员缺少自营商户成员关系: %v", err)
}
if membershipCount != 0 {
t.Fatalf("平台客服不应写入商户成员关系,实际 %d 条", membershipCount)
if membership.Role != model.MemberRoleOwner || !membership.IsDefault {
t.Fatalf("平台管理员成员关系不符: %+v", membership)
}
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)
if total != 1 || len(list) != 1 || list[0].ID != admin.ID {
t.Fatalf("平台账号列表应只包含管理员,实际 total=%d list=%+v", total, list)
}
// 平台客服 -> 平台管理员:补自营商户负责人上下文。
updated, err := svc.UpdatePlatformAdmin(support.ID, UpdatePlatformAdminInput{
Username: support.Username,
Nickname: support.Nickname,
updated, err := svc.UpdatePlatformAdmin(admin.ID, UpdatePlatformAdminInput{
Username: "platform-admin-renamed",
Nickname: "管理员一改",
Status: 1,
Role: model.RoleAdmin,
}, actor.ID)
}, 999)
if err != nil {
t.Fatalf("提升为管理员失败: %v", err)
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)
if updated.Username != "platform-admin-renamed" || updated.Nickname != "管理员一改" || updated.Role != model.RoleAdmin {
t.Fatalf("平台管理员更新结果不符: %+v", updated)
}
// 平台管理员 -> 平台客服:移除商户成员关系,回到 Tenant 合成上下文。
updated, err = svc.UpdatePlatformAdmin(support.ID, UpdatePlatformAdminInput{
Username: support.Username,
Nickname: support.Nickname,
Status: 1,
Role: model.RolePlatformSupport,
}, actor.ID)
merchantUser, err := svc.Create("platform-merchant-1", "password123", "商户账号", model.RoleMerchant, 0)
if err != nil {
t.Fatalf("降级为平台客服失败: %v", err)
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)
if _, err := svc.UpdatePlatformAdmin(merchantUser.ID, UpdatePlatformAdminInput{
Username: merchantUser.Username,
Status: 1,
}, 999); err == nil {
t.Fatal("平台账号接口不应允许管理商户账号")
}
}
func TestCannotDemoteLastPlatformAdmin(t *testing.T) {
func TestCannotDisableOrDeleteLastPlatformAdmin(t *testing.T) {
db := newServiceTestDB(t)
svc := NewUserService(db, nil)
admin, err := svc.CreatePlatformAdmin("only-role-admin", "password123", "唯一管理员")
admin, err := svc.CreatePlatformAdmin("only-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,
Status: 0,
}, 0); err == nil {
t.Fatal("最后一个启用的平台管理员不能被降级")
t.Fatal("最后一个启用的平台管理员不能被停用")
}
if err := svc.DeletePlatformAdmin(admin.ID, 0); err == nil {
t.Fatal("最后一个启用的平台管理员不能被删除")
}
}
+11 -57
View File
@@ -82,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 && role != model.RolePlatformSupport {
if role != model.RoleAdmin && role != model.RoleMerchant {
return nil, errors.New("无效的账号角色")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
@@ -145,14 +145,13 @@ 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,
Roles: []string{model.RoleAdmin, model.RolePlatformSupport},
Roles: []string{model.RoleAdmin},
})
if err != nil {
return nil, 0, err
@@ -221,23 +220,14 @@ 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)
// CreatePlatformUser 创建平台管理员账号,并写入自营商户负责人成员关系。
func (s *UserService) CreatePlatformUser(username, password, nickname string) (*model.User, error) {
return s.Create(username, password, nickname, model.RoleAdmin, 0)
}
// CreatePlatformAdmin creates an account with platform privileges and the
// hidden self-operated merchant context required by merchant-side routes.
// CreatePlatformAdmin 保留旧调用名,统一创建平台管理员。
func (s *UserService) CreatePlatformAdmin(username, password, nickname string) (*model.User, error) {
return s.CreatePlatformUser(username, password, nickname, model.RoleAdmin)
return s.CreatePlatformUser(username, password, nickname)
}
func (s *UserService) UpdatePlatformAdmin(id uint, in UpdatePlatformAdminInput, actorUserID uint) (*model.User, error) {
@@ -259,18 +249,11 @@ func (s *UserService) UpdatePlatformAdmin(id uint, in UpdatePlatformAdminInput,
}
return err
}
if user.Role != model.RoleAdmin && user.Role != model.RolePlatformSupport {
if user.Role != model.RoleAdmin {
return errors.New("只能管理平台账号")
}
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) {
// 停用最后一个启用的平台管理员会导致平台无法登录。
if user.Status == 1 && 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
@@ -288,39 +271,10 @@ func (s *UserService) UpdatePlatformAdmin(id uint, in UpdatePlatformAdminInput,
return errors.New("用户名已存在")
}
}
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)
@@ -383,7 +337,7 @@ func (s *UserService) DeletePlatformAdmin(id, actorUserID uint) error {
}
return err
}
if user.Role != model.RoleAdmin && user.Role != model.RolePlatformSupport {
if user.Role != model.RoleAdmin {
return errors.New("只能删除平台账号")
}
if user.Role == model.RoleAdmin && user.Status == 1 {
+2 -23
View File
@@ -23,7 +23,6 @@ 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() {
@@ -60,21 +59,8 @@ function MerchantPermissionRoute({ permission, children }: { permission?: string
return merchantPermissions.includes(permission) ? <>{children}</> : <Navigate to="/" replace />
}
function SupportQueryRoute({ children }: { children: ReactNode }) {
const { isAdmin, isPlatformSupport } = useAuth()
return isAdmin || isPlatformSupport ? <>{children}</> : <Navigate to="/" replace />
}
function HomeRoute() {
const { isPlatformSupport } = useAuth()
if (isPlatformSupport) return <Navigate to="/support-rock-tokens" replace />
return <Dashboard />
}
function MerchantCenterRoute() {
const { isPlatformSupport } = useAuth()
const location = useLocation()
if (isPlatformSupport) return <Navigate to="/merchant-orders" replace />
const requestedTab = new URLSearchParams(location.search).get('tab') || 'products'
const tab = requestedTab in tabPermissions ? requestedTab : 'products'
return (
@@ -97,7 +83,7 @@ function AppRoutes() {
</PrivateRoute>
}
>
<Route index element={<HomeRoute />} />
<Route index element={<Dashboard />} />
<Route path="merchant-center" element={<MerchantCenterRoute />} />
<Route path="merchant-products" element={<MerchantPermissionRoute permission="products:manage"><MerchantCenter fixedTab="products" title="商品列表" /></MerchantPermissionRoute>} />
<Route path="merchant-orders" element={<MerchantPermissionRoute permission="orders:manage"><MerchantCenter fixedTab="orders" title="发货订单" /></MerchantPermissionRoute>} />
@@ -139,14 +125,7 @@ function AppRoutes() {
</AdminRoute>
}
/>
<Route
path="support-rock-tokens"
element={
<SupportQueryRoute>
<SupportRockTokens />
</SupportQueryRoute>
}
/>
<Route path="support-rock-tokens" element={<Navigate to="/merchant-orders" replace />} />
<Route path="platform-users" element={<AdminRoute><PlatformUsers /></AdminRoute>} />
<Route path="open-api" element={<MerchantPermissionRoute permission="api:manage"><OpenApiDocs /></MerchantPermissionRoute>} />
<Route
-2
View File
@@ -103,7 +103,6 @@ export const userApi = {
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,
@@ -112,7 +111,6 @@ export const userApi = {
nickname?: string
password?: string
status: number
role?: 'admin' | 'platform_support'
},
) =>
request
+6 -28
View File
@@ -70,7 +70,6 @@ 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' },
],
},
{
@@ -123,21 +122,6 @@ const adminSections: SidebarSection[] = [
},
]
const platformSupportSections: SidebarSection[] = [
{
key: 'products',
label: '商品管理',
icon: <AppstoreOutlined />,
children: [{ key: 'support-rock', label: '客服查询', path: '/support-rock-tokens' }],
},
{
key: 'orders',
label: '订单管理',
icon: <OrderedListOutlined />,
children: [{ key: 'fulfillment-orders', label: '发货订单', path: '/merchant-orders' }],
},
]
const merchantSections: SidebarSection[] = [
{
key: 'dashboard',
@@ -190,7 +174,6 @@ 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'
@@ -245,13 +228,13 @@ export default function MainLayout() {
const [collapsed, setCollapsed] = useState<boolean>(() => {
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true'
})
const { user, logout, refreshProfile, isAdmin, isPlatformSupport, merchant, merchantPermissions } = useAuth()
const { user, logout, refreshProfile, isAdmin, merchant, merchantPermissions } = useAuth()
useEffect(() => {
// 平台角色保持全局视角,清除任何残留的商户上下文
if ((isAdmin || isPlatformSupport) && localStorage.getItem('merchant_id')) {
// 平台管理员保持全局视角,清除任何残留的商户上下文
if (isAdmin && localStorage.getItem('merchant_id')) {
localStorage.removeItem('merchant_id')
}
}, [isAdmin, isPlatformSupport])
}, [isAdmin])
const [passwordOpen, setPasswordOpen] = useState(false)
const [passwordSaving, setPasswordSaving] = useState(false)
const [passwordForm] = Form.useForm()
@@ -261,7 +244,6 @@ 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]
@@ -271,7 +253,7 @@ export default function MainLayout() {
))
return children.length > 0 ? [{ ...section, children }] : []
})
}, [isAdmin, isPlatformSupport, merchant?.features, merchantPermissions])
}, [isAdmin, merchant?.features, merchantPermissions])
const selectedKey = getSelectedKey(location.pathname, location.search)
const [openKeys, setOpenKeys] = useState<string[]>(() => loadStoredOpenKeys(sections, selectedKey))
@@ -343,7 +325,7 @@ export default function MainLayout() {
<div style={{ padding: '4px 0' }}>
<div style={{ fontWeight: 600, color: '#0f172a' }}>{user?.nickname || user?.username}</div>
<div style={{ fontSize: 12, color: '#64748b' }}>
角色:{isAdmin ? '平台超级管理员' : isPlatformSupport ? '平台客服' : '商户成员'}
角色:{isAdmin ? '平台超级管理员' : '商户成员'}
</div>
</div>
),
@@ -479,10 +461,6 @@ export default function MainLayout() {
<Tag color="blue" bordered={false} style={{ margin: 0, fontSize: 11, fontWeight: 600 }}>
管理员
</Tag>
) : isPlatformSupport ? (
<Tag color="purple" bordered={false} style={{ margin: 0, fontSize: 11, fontWeight: 600 }}>
平台客服
</Tag>
) : (
<Tag color="cyan" bordered={false} style={{ margin: 0, fontSize: 11, fontWeight: 600 }}>
商户
+5 -4
View File
@@ -60,7 +60,7 @@ export default function MerchantCenter({
fixedTab,
title = "商户中心",
}: MerchantCenterProps = {}) {
const { isAdmin, isPlatformSupport, user } = useAuth();
const { isAdmin, user } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const routeTab = useMemo(
@@ -726,6 +726,8 @@ export default function MerchantCenter({
}
};
// 客服查询只对平台管理员与「自用自动发货」商户展示,其他商户保持原发货订单页。
const canUseSupportQuery = isAdmin || merchant?.code === "self-order-site";
const tabItems = [
{
key: "products",
@@ -766,6 +768,7 @@ export default function MerchantCenter({
onRevokeLink={revokeDeliveryLink}
onRestoreLink={restoreDeliveryLink}
onCancelOrder={cancelOrder}
showSupportQuery={canUseSupportQuery}
/>
),
},
@@ -893,9 +896,7 @@ export default function MerchantCenter({
title={title}
subtitle={
merchant
? isPlatformSupport
? `${merchant.name} (${merchant.code}) · 平台客服`
: `${merchant.name} (${merchant.code}) · 当前身份: ${merchantRole ? roleText(merchantRole) : "-"}`
? `${merchant.name} (${merchant.code}) · 当前身份: ${merchantRole ? roleText(merchantRole) : "-"}`
: "商户配置与管理中心"
}
breadcrumbs={computedBreadcrumbs}
+6 -32
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Form, Input, Modal, Popconfirm, Select, Space, Switch, Table, Tabs, Tag, Typography, message } from 'antd'
import { Button, Form, Input, Modal, Popconfirm, 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,16 +15,6 @@ 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) {
@@ -79,7 +69,7 @@ export default function PlatformUsers() {
const openCreate = () => {
setEditingAdmin(null)
form.setFieldsValue({ username: '', nickname: '', password: '', status: true, role: 'admin' })
form.setFieldsValue({ username: '', nickname: '', password: '', status: true })
setModalOpen(true)
}
@@ -90,7 +80,6 @@ export default function PlatformUsers() {
nickname: record.nickname,
password: '',
status: record.status === 1,
role: record.role === 'platform_support' ? 'platform_support' : 'admin',
})
setModalOpen(true)
}, [form])
@@ -105,15 +94,13 @@ 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 || '',
role: values.role,
})
message.success('平台账号已创建')
}
@@ -228,16 +215,6 @@ export default function PlatformUsers() {
const columns: ColumnsType<User> = useMemo(() => [
{ title: '用户名', dataIndex: 'username', width: 220, render: (value) => <Typography.Text strong>{value}</Typography.Text> },
{ title: '昵称', dataIndex: 'nickname', width: 180, render: (value) => value || '-' },
{
title: '角色',
dataIndex: 'role',
width: 120,
render: (role) => (
<Tag color={role === 'platform_support' ? 'purple' : 'blue'}>
{platformRoleLabel(role)}
</Tag>
),
},
{ title: '手机号', width: 170, render: (_, record) => phoneCell(record) },
{ title: '绑定设备', width: 200, render: (_, record) => deviceCell(record) },
{ title: '状态', dataIndex: 'status', width: 100, render: (value) => value === 1 ? <Tag color="green">启用</Tag> : <Tag>停用</Tag> },
@@ -248,7 +225,7 @@ export default function PlatformUsers() {
render: (_, record) => record.id === currentUser?.id ? <Typography.Text type="secondary">当前账号</Typography.Text> : (
<Space size={0}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(record)}>编辑</Button>
<Popconfirm title={`确认删除平台账号「${record.username}」?`} description="删除后无法登录平台;平台客服的订单与客服查询权限会同时失效。" onConfirm={() => remove(record)} okButtonProps={{ danger: true }}>
<Popconfirm title={`确认删除平台账号「${record.username}」?`} description="删除后该账号将无法登录平台。" onConfirm={() => remove(record)} okButtonProps={{ danger: true }}>
<Button type="link" danger size="small" icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</Space>
@@ -343,7 +320,7 @@ export default function PlatformUsers() {
<div>
<PageHeader
title="账号管理"
subtitle="平台管理员与平台客服统一管理;平台客服仅可操作自营商户发货订单与查询全部绑定中 Token。"
subtitle="平台管理员账号统一管理;商户账号请在「商户账号」页签中管理。"
breadcrumbs={[{ title: '账号管理' }]}
extra={<Space><Button icon={<ReloadOutlined />} loading={loading} onClick={() => tab === 'admins' ? void loadAdmins() : void loadMerchantAccounts()}>刷新</Button>{tab === 'admins' && <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新建平台账号</Button>}</Space>}
/>
@@ -357,16 +334,13 @@ export default function PlatformUsers() {
okText="保存"
destroyOnHidden
>
<Form form={form} layout="vertical" preserve={false} initialValues={{ status: true, role: 'admin' }}>
<Form form={form} layout="vertical" preserve={false} initialValues={{ status: true }}>
<Form.Item label="用户名" name="username" rules={[{ required: true, message: '请输入用户名' }, { max: 64, message: '用户名不能超过 64 个字符' }]}>
<Input autoComplete="off" />
</Form.Item>
<Form.Item label="昵称" name="nickname" rules={[{ max: 64, message: '昵称不能超过 64 个字符' }]}>
<Input autoComplete="off" />
</Form.Item>
<Form.Item label="角色" name="role" rules={[{ required: true, message: '请选择角色' }]} extra={editingAdmin ? '角色变更后,该账号需重新登录生效' : undefined}>
<Select options={platformRoleOptions} />
</Form.Item>
<Form.Item label={editingAdmin ? '新密码' : '密码'} name="password" rules={editingAdmin ? [{ min: 6, message: '密码至少 6 位' }] : [{ required: true, message: '请输入密码' }, { min: 6, message: '密码至少 6 位' }]} extra={editingAdmin ? '留空表示不修改密码' : undefined}>
<Input.Password autoComplete="new-password" />
</Form.Item>
+12 -6
View File
@@ -9,7 +9,11 @@ import { buildTokenColumns } from './platformRock/tokenColumns'
import { TokenStatusLogDrawer } from './platformRock/TokenStatusLogDrawer'
import { VoucherPreviewModal } from './platformRock/VoucherPreviewModal'
export default function SupportRockTokens() {
export default function SupportRockTokens({
embedded = false,
}: {
embedded?: boolean
}) {
const [search, setSearch] = useState('')
const [query, setQuery] = useState('')
const [searched, setSearched] = useState(false)
@@ -192,11 +196,13 @@ export default function SupportRockTokens() {
return (
<div style={{ padding: '0 4px' }}>
<PageHeader
title="客服查询 · 绑定中 Token"
subtitle="仅查询绑定中的 Token(status=bound),只读;请输入查询条件后查询,默认不加载数据。"
extra={searched ? <Tag color="blue">共 {total} 条</Tag> : undefined}
/>
{!embedded && (
<PageHeader
title="客服查询 · 绑定中 Token"
subtitle="仅查询绑定中的 Token(status=bound),只读;请输入查询条件后查询,默认不加载数据。"
extra={searched ? <Tag color="blue">共 {total} 条</Tag> : undefined}
/>
)}
<Alert
type="info"
+23 -3
View File
@@ -36,6 +36,7 @@ import {
} from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import { merchantApi } from "../api";
import SupportRockTokens from "./SupportRockTokens";
import { formatDateTime } from "../utils/time";
import type {
ApiClient,
@@ -484,6 +485,7 @@ export function OrdersTab({
onRevokeLink,
onRestoreLink,
onCancelOrder,
showSupportQuery,
}: {
loading: boolean;
orders: PageResult<FulfillmentOrder>;
@@ -499,11 +501,13 @@ export function OrdersTab({
onRevokeLink: (orderNo: string) => void;
onRestoreLink: (orderNo: string) => void;
onCancelOrder: (orderNo: string, reason: string) => Promise<void> | void;
showSupportQuery: boolean;
}) {
const [keyword, setKeyword] = useState(filters.keyword);
const [cancelling, setCancelling] = useState<FulfillmentOrder | null>(null);
const [cancelReason, setCancelReason] = useState("");
const [cancelSubmitting, setCancelSubmitting] = useState(false);
const [showSupport, setShowSupport] = useState(false);
useEffect(() => {
setKeyword(filters.keyword);
@@ -707,15 +711,25 @@ export function OrdersTab({
<Space direction="vertical" style={{ width: "100%" }} size="middle">
<Space wrap style={{ width: "100%", justifyContent: "space-between" }}>
<Tabs
activeKey={orderSource}
onChange={(key) => onOrderSourceChange(key as "api" | "manual")}
activeKey={showSupport ? "support" : orderSource}
onChange={(key) => {
if (key === "support") {
setShowSupport(true);
return;
}
setShowSupport(false);
onOrderSourceChange(key as "api" | "manual");
}}
items={[
{ key: "api", label: "API 订单" },
{ key: "manual", label: "手动订单" },
...(showSupportQuery
? [{ key: "support", label: "客服查询" }]
: []),
]}
style={{ minWidth: 220 }}
/>
{canManage && (
{canManage && !showSupport && (
<Space>
<Button
type="primary"
@@ -727,6 +741,10 @@ export function OrdersTab({
</Space>
)}
</Space>
{showSupport ? (
<SupportRockTokens embedded />
) : (
<>
<Card size="small" style={{ borderRadius: 8 }}>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Space wrap>
@@ -767,6 +785,8 @@ export function OrdersTab({
scroll={{ x: 1780 }}
pagination={pageConfig(orders, onLoadOrders)}
/>
</>
)}
<Modal
title={`取消订单:${cancelling?.order_no || ""}`}
open={!!cancelling}
-1
View File
@@ -104,7 +104,6 @@ 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],
)
-1
View File
@@ -14,7 +14,6 @@ export interface AuthState {
merchantLoading: boolean
refreshMerchantContext: () => Promise<void>
isAdmin: boolean
isPlatformSupport: boolean
}
// Context 与 useAuth 单独放在本文件:auth.tsx 因此只导出 AuthProvider 组件,
+1 -1
View File
@@ -2,7 +2,7 @@ export interface User {
id: number
username: string
nickname: string
role: 'admin' | 'merchant' | 'platform_support'
role: 'admin' | 'merchant'
status: number
created_at: string
phone?: string