From 5feb4e8571e045508103fab520a4568c57f63309 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 28 Aug 2026 22:16:51 +0800 Subject: [PATCH] feat(web): enforce server-driven GoAuto menus (#138) --- docs/02-architecture-and-code-map.md | 6 +- server/app/goauto/access/modules_test.go | 38 +++-- web/src/permission.js | 2 +- web/src/router/index.js | 143 ------------------- web/src/store/modules/permission.js | 40 +++--- web/src/store/modules/user.js | 2 + web/tests/e2e/goauto-menu-permission.spec.ts | 114 +++++++++++++++ 7 files changed, 166 insertions(+), 179 deletions(-) create mode 100644 web/tests/e2e/goauto-menu-permission.spec.ts diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 99c6509..5b3d0e1 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- -wiki_revision: 267a75be046b2faedddbad86232e2c78b08ba92c -synchronized_at: 2026-08-28T13:17:46Z +wiki_revision: 40b34ee81f4207de6797b1a81ded6459b6dd776d +synchronized_at: 2026-08-28T14:09:31Z # 架构与代码地图 @@ -130,7 +130,7 @@ Android Portal/Agent | 备货采购服务端路径 | `purchase_task.task_type` 与迁移 `1787885300000_stock_purchase.go`;`POST /api/admin/v1/purchase-tasks/stock` 由 `server/app/goauto/purchase/service.go` 校验 PDD 当前可选规格并固化 `direct_select`,复用既有任务状态机和设备/账号互斥;重试、替换和 SYB 回填显式排除 `stock` | | Admin 采购任务列表、详情与人工处理 | `web/src/views/goauto/purchase-tasks/`、`web/src/api/goauto/purchase-tasks.js`;创建入口不在本模块 | | Admin AI 规格匹配设置 | `web/src/views/goauto/ai-matching-settings/`、`web/src/api/goauto/ai-matching-settings.js`;管理员可查看、维护和测试 Provider(包括内部明文 API Key);采购员菜单硬排除,仍只可通过受控 API 查看启用状态 | -| GoAuto 系统菜单与采购员权限基线 | `server/app/goauto/access/` 统一声明 11 个模块、路由元数据和 Admin API 权限矩阵;迁移 `1787885400000_goauto_menus.go` 幂等维护 `sys_menu`、`sys_menu_api_rule`、采购员默认菜单绑定和 Casbin 固定白名单。菜单可见性与 API 授权彼此独立 | +| GoAuto 系统菜单与采购员权限基线 | `server/app/goauto/access/` 统一声明 11 个模块、路由元数据和 Admin API 权限矩阵;迁移 `1787885400000_goauto_menus.go` 幂等维护 `sys_menu`、`sys_menu_api_rule`、采购员默认菜单绑定和 Casbin 固定白名单。`web/src/router/index.js` 只保留公共路由,`web/src/store/modules/permission.js` 在登录时根据 `GET /api/v1/menurole` 返回结果注册业务路由与侧栏;退出、凭据异常和切换角色会清除旧动态路由,未授权深链接回退工作台。菜单可见性与 API 授权彼此独立 | | Admin 失败采购任务批量重试 | `POST /api/admin/v1/purchase-tasks/batch-retry`;服务端 `server/app/goauto/purchase/retry.go` 负责资格判定、逐项幂等创建与部分成功结果,Admin 页面只允许选择服务端标记可重试的行;不修改 Android Agent | | SYB 店铺管理页面与接口封装 | `web/src/views/goauto/syb-shops/`、`web/src/api/goauto/syb-shops.js`;确认原型快照 `prototypes/49/v1/index.html` | | SYB 异步导入、商品筛选、当前页采购选择/确认/逐条结果与同步记录页面 | `web/src/views/goauto/syb-products/`、`web/src/views/goauto/syb-sync-runs/`、`web/src/api/goauto/syb-products.js`、`web/src/api/goauto/purchase-tasks.js`;商品列表按店铺名称包含匹配,并支持最多 100 个多行订单号精确筛选;确认原型见 #44 设计证据,导入原型快照为 `prototypes/50/v2/index.html` | diff --git a/server/app/goauto/access/modules_test.go b/server/app/goauto/access/modules_test.go index daf6ce1..61916ed 100644 --- a/server/app/goauto/access/modules_test.go +++ b/server/app/goauto/access/modules_test.go @@ -44,7 +44,7 @@ func TestAIMatchingIsHardHiddenFromPurchaser(t *testing.T) { } } -func TestGoAutoModuleRoutesMatchCurrentWebRouter(t *testing.T) { +func TestGoAutoRoutesAreGeneratedFromServerMenus(t *testing.T) { _, fileName, _, ok := runtime.Caller(0) if !ok { t.Fatal("cannot locate test source") @@ -56,15 +56,33 @@ func TestGoAutoModuleRoutesMatchCurrentWebRouter(t *testing.T) { } router := string(content) for _, module := range GoAutoModules() { - want := []string{ - "path: '" + module.Path + "'", - "component: () => import('@/views" + module.Component + "')", - "name: '" + module.RouteName + "'", - } - for _, fragment := range want { - if !strings.Contains(router, fragment) { - t.Fatalf("Web router is inconsistent with module %s: missing %q", module.Key, fragment) - } + if strings.Contains(router, "name: '"+module.RouteName+"'") { + t.Fatalf("GoAuto route %s must not be a public constant route", module.RouteName) } } + + permissionPath := filepath.Join(filepath.Dir(fileName), "..", "..", "..", "..", "web", "src", "store", "modules", "permission.js") + content, err = os.ReadFile(permissionPath) + if err != nil { + t.Fatal(err) + } + permission := string(content) + for _, fragment := range []string{ + "getRoutes()", + "generaMenu(dynamicRoutes, loadMenuData)", + "commit('SET_SIDEBAR_ROUTERS', constantRoutes.concat(sidebarRoutes))", + } { + if !strings.Contains(permission, fragment) { + t.Fatalf("Web permission routing is missing %q", fragment) + } + } + + userPath := filepath.Join(filepath.Dir(fileName), "..", "..", "..", "..", "web", "src", "store", "modules", "user.js") + content, err = os.ReadFile(userPath) + if err != nil { + t.Fatal(err) + } + if strings.Count(string(content), "resetRouter()") < 3 { + t.Fatal("login role changes must clear previously registered dynamic routes") + } } diff --git a/web/src/permission.js b/web/src/permission.js index cd25faa..36c498b 100644 --- a/web/src/permission.js +++ b/web/src/permission.js @@ -47,7 +47,7 @@ router.beforeEach(async(to, from, next) => { next({ ...to, replace: true }) } catch (error) { // remove token and go to login page to re-login - // await store.dispatch('user/resetToken') + await store.dispatch('user/resetToken') ElMessage.error(error || 'Has Error') next(`/login?redirect=${to.path}`) NProgress.done() diff --git a/web/src/router/index.js b/web/src/router/index.js index 55e0d82..aaae951 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -80,149 +80,6 @@ export const constantRoutes = [ } ] }, - { - path: '/devices', - component: Layout, - redirect: '/devices/index', - children: [ - { - path: 'index', - component: () => import('@/views/goauto/devices/index'), - name: 'GoAutoDeviceList', - meta: { title: '设备列表', icon: 'monitor' } - } - ] - }, - { - path: '/pdd-products', - component: Layout, - redirect: '/pdd-products/index', - children: [ - { - path: 'index', - component: () => import('@/views/goauto/pdd-products/index'), - name: 'GoAutoPddProducts', - meta: { title: 'PDD 商品', icon: 'shopping' } - } - ] - }, - { - path: '/shopee-products', - component: Layout, - redirect: '/shopee-products/index', - children: [ - { - path: 'index', - component: () => import('@/views/goauto/shopee-products/index'), - name: 'GoAutoShopeeProducts', - meta: { title: '虾皮商品', icon: 'goods' } - } - ] - }, - { - path: '/syb-products', - component: Layout, - redirect: '/syb-products/index', - children: [ - { - path: 'index', - component: () => import('@/views/goauto/syb-products/index'), - name: 'GoAutoSybProducts', - meta: { title: 'SYB 商品', icon: 'tickets' } - } - ] - }, - { - path: '/syb-shops', - component: Layout, - redirect: '/syb-shops/index', - children: [ - { - path: 'index', - component: () => import('@/views/goauto/syb-shops/index'), - name: 'GoAutoSybShops', - meta: { title: 'SYB 店铺', icon: 'shopping' } - } - ] - }, - { - path: '/syb-sync-runs', - component: Layout, - redirect: '/syb-sync-runs/index', - children: [ - { - path: 'index', - component: () => import('@/views/goauto/syb-sync-runs/index'), - name: 'GoAutoSybSyncRuns', - meta: { title: 'SYB 同步记录', icon: 'time' } - } - ] - }, - { - path: '/syb-inner-codes', - component: Layout, - redirect: '/syb-inner-codes/index', - children: [ - { - path: 'index', - component: () => import('@/views/goauto/syb-inner-codes/index'), - name: 'GoAutoSybInnerCodes', - meta: { title: '档口入库码', icon: 'list' } - } - ] - }, - { - path: '/collection-rules', - component: Layout, - redirect: '/collection-rules/index', - children: [ - { - path: 'index', - component: () => import('@/views/goauto/collection-rules/index'), - name: 'GoAutoCollectionRules', - meta: { title: '采集规则', icon: 'code' } - } - ] - }, - { - path: '/collection-tasks', - component: Layout, - redirect: '/collection-tasks/index', - children: [ - { - path: 'index', - component: () => import('@/views/goauto/collection-tasks/index'), - name: 'GoAutoCollectionTasks', - meta: { title: '采集任务', icon: 'list' } - } - ] - }, - { - path: '/purchase-tasks', - component: Layout, - redirect: '/purchase-tasks/index', - children: [ - { - path: 'index', - component: () => import('@/views/goauto/purchase-tasks/index'), - name: 'GoAutoPurchaseTasks', - meta: { title: '采购管理', icon: 'shopping' } - } - ] - }, - { - path: '/ai-matching-settings', - component: Layout, - redirect: '/ai-matching-settings/index', - children: [ - { - path: 'index', - component: () => import('@/views/goauto/ai-matching-settings/index'), - name: 'GoAutoAiMatchingSettings', - meta: { title: 'AI 规格匹配', icon: 'setting' } - } - ] - }, { path: '/profile', component: Layout, diff --git a/web/src/store/modules/permission.js b/web/src/store/modules/permission.js index 63988ce..c7d8ba7 100644 --- a/web/src/store/modules/permission.js +++ b/web/src/store/modules/permission.js @@ -138,34 +138,30 @@ const mutations = { const actions = { generateRoutes({ commit }, roles) { - return new Promise(resolve => { + return new Promise((resolve, reject) => { const loadMenuData = [] getRoutes().then(response => { - // console.log(JSON.stringify(response)) - let data = response if (response.code !== 200) { - this.$message({ - message: '菜单数据加载异常', - type: 0 - }) - } else { - data = response.data - Object.assign(loadMenuData, data) - - const dynamicRoutes = [] - generaMenu(dynamicRoutes, loadMenuData) - dynamicRoutes.push({ path: '/:pathMatch(.*)*', redirect: '/', hidden: true }) - commit('SET_ROUTES', dynamicRoutes) - const sidebarRoutes = [] - generaMenu(sidebarRoutes, loadMenuData) - commit('SET_SIDEBAR_ROUTERS', constantRoutes.concat(sidebarRoutes)) - commit('SET_DEFAULT_ROUTES', sidebarRoutes) - commit('SET_TOPBAR_ROUTES', sidebarRoutes) - resolve(dynamicRoutes) + reject(new Error('菜单数据加载异常')) + return } + + const data = Array.isArray(response.data) ? response.data : [] + Object.assign(loadMenuData, data) + + const dynamicRoutes = [] + generaMenu(dynamicRoutes, loadMenuData) + dynamicRoutes.push({ path: '/:pathMatch(.*)*', redirect: '/', hidden: true }) + commit('SET_ROUTES', dynamicRoutes) + const sidebarRoutes = [] + generaMenu(sidebarRoutes, loadMenuData) + commit('SET_SIDEBAR_ROUTERS', constantRoutes.concat(sidebarRoutes)) + commit('SET_DEFAULT_ROUTES', sidebarRoutes) + commit('SET_TOPBAR_ROUTES', sidebarRoutes) + resolve(dynamicRoutes) }).catch(error => { - console.log(error) + reject(error) }) }) } diff --git a/web/src/store/modules/user.js b/web/src/store/modules/user.js index 0811a8e..a037d8a 100644 --- a/web/src/store/modules/user.js +++ b/web/src/store/modules/user.js @@ -89,6 +89,7 @@ const actions = { commit('SET_PERMISSIONS', []) removeToken() storage.clear() + resetRouter() resolve() }).catch(error => { reject(error) @@ -114,6 +115,7 @@ const actions = { return new Promise(resolve => { commit('SET_TOKEN', '') removeToken() + resetRouter() resolve() }) }, diff --git a/web/tests/e2e/goauto-menu-permission.spec.ts b/web/tests/e2e/goauto-menu-permission.spec.ts new file mode 100644 index 0000000..cda9269 --- /dev/null +++ b/web/tests/e2e/goauto-menu-permission.spec.ts @@ -0,0 +1,114 @@ +import { expect, test } from '@playwright/test' + +const modules = [ + ['/devices', '/goauto/devices/index', 'GoAutoDeviceList', '设备列表', 'monitor'], + ['/pdd-products', '/goauto/pdd-products/index', 'GoAutoPddProducts', 'PDD 商品', 'shopping'], + ['/shopee-products', '/goauto/shopee-products/index', 'GoAutoShopeeProducts', '虾皮商品', 'goods'], + ['/syb-products', '/goauto/syb-products/index', 'GoAutoSybProducts', 'SYB 商品', 'tickets'], + ['/syb-shops', '/goauto/syb-shops/index', 'GoAutoSybShops', 'SYB 店铺', 'shopping'], + ['/syb-sync-runs', '/goauto/syb-sync-runs/index', 'GoAutoSybSyncRuns', 'SYB 同步记录', 'time'], + ['/syb-inner-codes', '/goauto/syb-inner-codes/index', 'GoAutoSybInnerCodes', '档口入库码', 'list'], + ['/collection-rules', '/goauto/collection-rules/index', 'GoAutoCollectionRules', '采集规则', 'code'], + ['/collection-tasks', '/goauto/collection-tasks/index', 'GoAutoCollectionTasks', '采集任务', 'list'], + ['/purchase-tasks', '/goauto/purchase-tasks/index', 'GoAutoPurchaseTasks', '采购管理', 'shopping'], + ['/ai-matching-settings', '/goauto/ai-matching-settings/index', 'GoAutoAiMatchingSettings', 'AI 规格匹配', 'setting'] +] as const + +function menu(module: typeof modules[number]) { + const [path, component, name, title, icon] = module + return { + path, + component: 'Layout', + visible: '0', + menuName: `${name}Root`, + title, + icon, + children: [{ + path: 'index', + component, + visible: '0', + menuName: name, + title, + icon + }] + } +} + +async function mockSession(page: any, role: 'admin' | 'purchaser', visibleModules: typeof modules[number][]) { + await page.context().addCookies([{ + name: 'Admin-Token', + value: 'menu-permission-test-token', + domain: 'localhost', + path: '/' + }]) + await page.route('**/api/**', async (route: any) => { + const url = new URL(route.request().url()) + if (url.pathname.startsWith('/src/api/')) return route.continue() + if (url.pathname.endsWith('/api/v1/getinfo')) { + return route.fulfill({ json: { code: 200, data: { roles: [role], name: role, avatar: '', introduction: '', permissions: [] } } }) + } + if (url.pathname.endsWith('/api/v1/menurole')) { + return route.fulfill({ json: { code: 200, data: visibleModules.map(menu) } }) + } + return route.fulfill({ json: { code: 200, data: [] } }) + }) +} + +test('管理员菜单由服务端返回并包含全部 GoAuto 模块', async ({ page }) => { + await mockSession(page, 'admin', [...modules]) + await page.goto('/#/dashboard') + + for (const module of modules) { + await expect(page.locator('.sidebar-container').getByText(module[3], { exact: true }).first()).toBeVisible() + } +}) + +test('采购员只看到授权菜单且不能通过地址访问 AI 配置', async ({ page }) => { + const purchaserModules = [modules[4], modules[9]] + await mockSession(page, 'purchaser', purchaserModules) + await page.goto('/#/dashboard') + + await expect(page.locator('.sidebar-container').getByText('SYB 店铺', { exact: true }).first()).toBeVisible() + await expect(page.locator('.sidebar-container').getByText('采购管理', { exact: true }).first()).toBeVisible() + await expect(page.locator('.sidebar-container').getByText('AI 规格匹配', { exact: true })).toHaveCount(0) + + await page.goto('/#/ai-matching-settings/index') + await expect(page).toHaveURL(/#\/dashboard$/) + await expect(page.locator('.sidebar-container').getByText('AI 规格匹配', { exact: true })).toHaveCount(0) +}) + +test('没有 GoAuto 菜单权限时仍可进入工作台', async ({ page }) => { + await mockSession(page, 'purchaser', []) + await page.goto('/#/purchase-tasks/index') + + await expect(page).toHaveURL(/#\/dashboard$/) + await expect(page.getByText('工作台', { exact: true }).first()).toBeVisible() + await expect(page.locator('.sidebar-container').getByText('采购管理', { exact: true })).toHaveCount(0) +}) + +test('角色菜单加载失败时清除会话并返回登录页', async ({ page }) => { + await page.context().addCookies([{ + name: 'Admin-Token', + value: 'menu-permission-test-token', + domain: 'localhost', + path: '/' + }]) + await page.route('**/api/**', async route => { + const url = new URL(route.request().url()) + if (url.pathname.startsWith('/src/api/')) return route.continue() + if (url.pathname.endsWith('/api/v1/getinfo')) { + return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: 'admin', avatar: '', introduction: '', permissions: [] } } }) + } + if (url.pathname.endsWith('/api/v1/menurole')) { + return route.fulfill({ json: { code: 500, message: '菜单服务不可用' } }) + } + return route.fulfill({ json: { code: 200, data: [] } }) + }) + + await page.goto('/#/dashboard') + await expect(page).toHaveURL(/#\/login\?redirect=\/dashboard$/) + await expect.poll(async () => { + const cookies = await page.context().cookies() + return cookies.some(cookie => cookie.name === 'Admin-Token') + }).toBe(false) +})