From 2b3a1b111b9a4de6f84fd379c06ef1bd6551c199 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 13 Aug 2026 12:52:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E8=B4=A6=E5=8F=B7=E7=AE=A1=E7=90=86=E4=B8=8E?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=98=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/user.go | 22 ++++++-- backend/internal/router/router.go | 1 + backend/internal/service/merchant_test.go | 22 ++++++++ backend/internal/service/user.go | 57 ++++++++++++++++++-- frontend/src/App.tsx | 2 + frontend/src/api/index.ts | 2 + frontend/src/layouts/MainLayout.tsx | 7 +++ frontend/src/pages/PlatformUsers.tsx | 64 +++++++++++++++++++++++ 8 files changed, 167 insertions(+), 10 deletions(-) create mode 100644 frontend/src/pages/PlatformUsers.tsx diff --git a/backend/internal/handler/user.go b/backend/internal/handler/user.go index 2fe4b3d..acd2cda 100644 --- a/backend/internal/handler/user.go +++ b/backend/internal/handler/user.go @@ -4,6 +4,7 @@ import ( "strconv" "affiliate_dash/internal/middleware" + "affiliate_dash/internal/model" "affiliate_dash/internal/pkg/response" "affiliate_dash/internal/service" @@ -22,11 +23,13 @@ func (h *UserHandler) List(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) q := service.UserListQuery{ - MerchantID: middleware.GetMerchantID(c), - Page: page, - Size: size, - Keyword: c.Query("keyword"), - Role: c.Query("role"), + Page: page, + Size: size, + Keyword: c.Query("keyword"), + Role: c.Query("role"), + } + if middleware.GetRole(c) != model.RoleAdmin { + q.MerchantID = middleware.GetMerchantID(c) } if s := c.Query("status"); s != "" { v, _ := strconv.Atoi(s) @@ -78,3 +81,12 @@ func (h *UserHandler) UpdateStatus(c *gin.Context) { } response.OK(c, nil) } + +func (h *UserHandler) Delete(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := h.svc.Delete(uint(id), middleware.GetUserID(c)); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, nil) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index be2eb62..e2e5d26 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -195,6 +195,7 @@ func Setup(h *Handlers) *gin.Engine { admin.GET("/users", h.User.List) admin.POST("/users", h.User.Create) admin.PATCH("/users/:id/status", h.User.UpdateStatus) + admin.DELETE("/users/:id", h.User.Delete) admin.GET("/platform/merchants", h.Merchant.ListPlatformMerchants) admin.POST("/platform/merchants", h.Merchant.CreateMerchant) admin.PATCH("/platform/merchants/:id", h.Merchant.UpdateMerchantSettings) diff --git a/backend/internal/service/merchant_test.go b/backend/internal/service/merchant_test.go index 3c9d716..dfebf33 100644 --- a/backend/internal/service/merchant_test.go +++ b/backend/internal/service/merchant_test.go @@ -202,3 +202,25 @@ func TestAuthServiceChangePasswordVerifiesCurrentPassword(t *testing.T) { t.Fatal("old password should no longer match") } } + +func TestUserServiceCannotDeleteOrDisableLastAdmin(t *testing.T) { + db := newServiceTestDB(t) + svc := NewUserService(db, nil) + admin, err := svc.Create("only-admin", "password123", "唯一管理员", model.RoleAdmin, 0) + if err != nil { + t.Fatalf("create admin: %v", err) + } + if err := svc.UpdateStatus(admin.ID, 0); err == nil { + t.Fatal("should not disable the last enabled admin") + } + other, err := svc.Create("other-admin", "password123", "另一管理员", model.RoleAdmin, 0) + if err != nil { + t.Fatalf("create second admin: %v", err) + } + if err := svc.Delete(other.ID, admin.ID); err != nil { + t.Fatalf("delete non-current admin: %v", err) + } + if err := svc.Delete(admin.ID, other.ID); err == nil { + t.Fatal("should not delete the last enabled admin") + } +} diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index edf8081..b435963 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -113,12 +113,59 @@ func (s *UserService) Create(username, password, nickname, role string, merchant } func (s *UserService) UpdateStatus(id uint, status int) error { - res := s.db.Model(&model.User{}).Where("id = ?", id).Update("status", status) - if res.Error != nil { - return res.Error + if status != 0 && status != 1 { + return errors.New("用户状态无效") } - if res.RowsAffected == 0 { + return s.db.Transaction(func(tx *gorm.DB) error { + var user model.User + if err := tx.First(&user, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("用户不存在") + } + return err + } + if user.Role == model.RoleAdmin && user.Status == 1 && 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 + } + if admins <= 1 { + return errors.New("至少保留一个启用的平台管理员") + } + } + return tx.Model(&user).Update("status", status).Error + }) +} + +// Delete removes a platform account and its merchant memberships. It protects the current +// account and the final enabled platform administrator so the platform cannot be locked out. +func (s *UserService) Delete(id, actorUserID uint) error { + if id == 0 { return errors.New("用户不存在") } - return nil + if id == actorUserID { + return errors.New("不能删除当前登录账号") + } + return s.db.Transaction(func(tx *gorm.DB) error { + var user model.User + if err := tx.First(&user, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("用户不存在") + } + return err + } + if user.Role == model.RoleAdmin && user.Status == 1 { + var admins int64 + if err := tx.Model(&model.User{}).Where("role = ? AND status = ?", model.RoleAdmin, 1).Count(&admins).Error; err != nil { + return err + } + if admins <= 1 { + return errors.New("至少保留一个启用的平台管理员") + } + } + if err := tx.Where("user_id = ?", id).Delete(&model.MerchantMember{}).Error; err != nil { + return err + } + return tx.Delete(&user).Error + }) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 871e99a..7c876d5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import MerchantCenter from './pages/MerchantCenter' import MerchantRecharge from './pages/MerchantRecharge' import AlertSettings from './pages/AlertSettings' import PlatformMerchants from './pages/PlatformMerchants' +import PlatformUsers from './pages/PlatformUsers' import type { ReactNode } from 'react' function PrivateRoute({ children }: { children: ReactNode }) { const { token } = useAuth() @@ -85,6 +86,7 @@ function AppRoutes() { } /> + } /> } /> r.data.data as User), updateStatus: (id: number, status: number) => request.patch(`/users/${id}/status`, { status }).then((r) => r.data.data), + remove: (id: number) => + request.delete(`/users/${id}`).then((r) => r.data.data), } export const merchantApi = { diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 844f1d7..5ddc7f2 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -74,6 +74,12 @@ const adminSections: SidebarSection[] = [ icon: , children: [{ key: 'shop-list', label: '平台商户', path: '/platform-merchants' }], }, + { + key: 'accounts', + label: '账号管理', + icon: , + children: [{ key: 'platform-users', label: '平台账号', path: '/platform-users' }], + }, { key: 'orders', label: '订单管理', @@ -180,6 +186,7 @@ function getSelectedKey(pathname: string, search: string) { if (pathname.startsWith('/open-api')) return 'api-docs' if (pathname.startsWith('/api-debug')) return 'api-debug' if (pathname.startsWith('/platform-merchants')) return 'shop-list' + if (pathname.startsWith('/platform-users')) return 'platform-users' return 'dashboard' } diff --git a/frontend/src/pages/PlatformUsers.tsx b/frontend/src/pages/PlatformUsers.tsx new file mode 100644 index 0000000..69fd283 --- /dev/null +++ b/frontend/src/pages/PlatformUsers.tsx @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useState } from 'react' +import { Button, Popconfirm, Table, Tag, Typography, message } from 'antd' +import { DeleteOutlined, ReloadOutlined } from '@ant-design/icons' +import type { ColumnsType } from 'antd/es/table' +import { userApi } from '../api' +import { PageHeader } from '../components/PageHeader' +import type { PageResult, User } from '../types' +import { formatDateTime } from '../utils/time' +import { useAuth } from '../store/auth' + +export default function PlatformUsers() { + const { user: currentUser } = useAuth() + const [data, setData] = useState>({ list: [], total: 0, page: 1, size: 20 }) + const [loading, setLoading] = useState(false) + + const load = useCallback(async (page = data.page, size = data.size) => { + setLoading(true) + try { + setData(await userApi.list({ page, size })) + } catch (e) { + message.error(e instanceof Error ? e.message : '加载失败') + } finally { + setLoading(false) + } + }, [data.page, data.size]) + + useEffect(() => { + void load() + }, [load]) + + const remove = async (record: User) => { + try { + await userApi.remove(record.id) + message.success('账号已删除') + load() + } catch (e) { + message.error(e instanceof Error ? e.message : '删除失败') + } + } + + const columns: ColumnsType = [ + { title: '用户名', dataIndex: 'username', width: 220, render: (value) => {value} }, + { title: '昵称', dataIndex: 'nickname', width: 180, render: (value) => value || '-' }, + { title: '账号类型', dataIndex: 'role', width: 140, render: (value) => {value === 'admin' ? '平台管理员' : '商户账号'} }, + { title: '状态', dataIndex: 'status', width: 100, render: (value) => value === 1 ? 启用 : 停用 }, + { title: '创建时间', dataIndex: 'created_at', width: 180, render: formatDateTime }, + { + title: '操作', + width: 120, + render: (_, record) => record.id === currentUser?.id ? 当前账号 : ( + remove(record)} okButtonProps={{ danger: true }}> + + + ), + }, + ] + + return ( +
+ } loading={loading} onClick={() => load()}>刷新} /> + load(page, size) }} /> + + ) +}