feat(web): enforce server-driven GoAuto menus (#138)
This commit is contained in:
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -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` |
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user