feat(web): enforce server-driven GoAuto menus (#138)

This commit is contained in:
QiuSW
2026-08-28 22:16:51 +08:00
parent 9916ed5ccf
commit 5feb4e8571
7 changed files with 166 additions and 179 deletions
+3 -3
View File
@@ -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` |
+28 -10
View File
@@ -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")
}
}
+1 -1
View File
@@ -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()
-143
View File
@@ -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,
+18 -22
View File
@@ -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)
})
})
}
+2
View File
@@ -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)
})