diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 41208cf..4c47b2e 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: 07e1ba0c8ee38e7758cf142a272af95fdd046523 -synchronized_at: 2026-08-28T15:20:51Z +wiki_revision: dc5ccd2104e8ded715c0c0bb23d3827dc9a7fd7a +synchronized_at: 2026-08-29T01:39:35Z # 架构与代码地图 @@ -268,3 +268,12 @@ PddProductDetailCollector - `pdd_product_color_image` 保存图片路径、类型、字节数、宽高、来源任务与设备;来源任务继续关联其 `rule_snapshot`。同商品同颜色唯一,更新成功后删除被替换的受控目录旧文件。 - 上传处理沿用既有 `/static/uploadfile` 静态能力,但使用独立 Agent 受控接口和 `goauto-color` 子目录,不复用公共上传入口。 - 图片旁路独立于结构化结果状态机。Android 先安全提交结果,再上传图片;任何图片异常只写本地脱敏诊断,不触发失败提交或任务状态回滚。 + + +## Admin GoAuto 分组导航与权限(#142) + +- `server/app/goauto/access/modules.go` 是 11 个 GoAuto 页面模块及两个一级菜单组的代码事实来源;本地迁移 `1787885600000_goauto_menu_groups.go` 创建结构性父菜单,并把既有页面菜单直接迁入父组。 +- 「采集采购」依次包含:SYB 商品、SYB 同步记录、档口入库码、虾皮商品、PDD 商品、采集任务、采购管理;「采采管理」依次包含:SYB 店铺、采集规则、设备列表、AI 规格匹配。 +- 迁移保留页面菜单 ID、路由、组件和 API 关联,移除旧的 11 个一级模块根菜单;导航固定为“分组 → 页面”两级,不增加第三级。 +- Admin 角色继续通过角色菜单查询取得全部 GoAuto 页面;采购员获得两个父组和除 AI 规格匹配外的 10 个页面,AI 页面及入口均不可见。 +- Web 继续通过 `/api/v1/menurole` 动态生成路由;直接访问组内页面时展开对应父组并高亮当前页面。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 077501f..88788b8 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- -wiki_revision: 79bb0b8452c95ca34d3193f0178199614c997edd -synchronized_at: 2026-08-28T15:21:02Z +wiki_revision: add9c709daea958eb0db9e2f313e8ca5276072a4 +synchronized_at: 2026-08-29T01:39:44Z # 业务规则与术语 @@ -350,3 +350,11 @@ synchronized_at: 2026-08-28T15:21:02Z - 服务端只接受该任务已提交颜色维度中的 JPEG,校验 Device Token、任务归属、终态、内容、尺寸和数量;按“PDD 商品 + 颜色值”维护最新一张,记录来源 task、device,并沿任务关联不可变规则快照。 - 只允许保存裁剪后的商品图片区域;裁剪结果不得包含账号、地址、订单、支付及其他个人数据。原始控件树、XML、整屏截图仍禁止保存。 - 诊断阶段 `COLOR_IMAGE` 只记录成功及不支持、定位失败、裁剪失败、压缩失败、上传失败分类,不记录颜色文案、坐标或图片内容。 + + +## Admin 采集采购与采采管理导航(#142) + +- 「采集采购」固定顺序为:SYB 商品、SYB 同步记录、档口入库码、虾皮商品、PDD 商品、采集任务、采购管理。 +- 「采采管理」固定顺序为:SYB 店铺、采集规则、设备列表、AI 规格匹配。 +- Admin 可见两个分组及全部 11 个页面;采购员可见两个分组和除 AI 规格匹配外的 10 个页面。父组只承担导航结构,不新增业务权限。 +- 分组迁移保留既有页面菜单 ID、URL、组件和 API 权限关联,删除旧的 11 个一级模块根菜单;菜单只保留“分组 → 页面”两级。 diff --git a/server/app/goauto/access/modules.go b/server/app/goauto/access/modules.go index 6c5e541..f480cfe 100644 --- a/server/app/goauto/access/modules.go +++ b/server/app/goauto/access/modules.go @@ -32,6 +32,57 @@ type ModuleDefinition struct { APIs []APIPermission } +const ( + MenuGroupCollectionPurchase = "collection_purchase" + MenuGroupCollectionManagement = "collection_management" +) + +// MenuGroupDefinition describes the two structural navigation groups used by +// Admin. ModuleKeys is ordered and therefore also defines child menu sorting. +type MenuGroupDefinition struct { + Key string + MenuName string + Title string + Path string + Icon string + Sort int + ModuleKeys []string +} + +var goAutoMenuGroupMetadata = []MenuGroupDefinition{ + { + Key: MenuGroupCollectionPurchase, + MenuName: "GoAutoCollectionPurchase", + Title: "采集采购", + Path: "/collection-purchase", + Icon: "shopping", + Sort: 50, + ModuleKeys: []string{ + ModuleSYBProducts, + ModuleSYBSyncRuns, + ModuleSYBInnerCodes, + ModuleShopeeProducts, + ModulePDDProducts, + ModuleCollectionTasks, + ModulePurchaseTasks, + }, + }, + { + Key: MenuGroupCollectionManagement, + MenuName: "GoAutoCollectionManagement", + Title: "采采管理", + Path: "/collection-management", + Icon: "system", + Sort: 51, + ModuleKeys: []string{ + ModuleSYBShops, + ModuleCollectionRules, + ModuleDevices, + ModuleAIMatching, + }, + }, +} + var goAutoModuleMetadata = []ModuleDefinition{ {Key: ModuleDevices, Title: "设备列表", Path: "/devices", RouteName: "GoAutoDeviceList", Component: "/goauto/devices/index", Icon: "monitor", Sort: 50, PurchaserDefault: true}, {Key: ModulePDDProducts, Title: "PDD 商品", Path: "/pdd-products", RouteName: "GoAutoPddProducts", Component: "/goauto/pdd-products/index", Icon: "shopping", Sort: 51, PurchaserDefault: true}, @@ -61,6 +112,17 @@ func GoAutoModules() []ModuleDefinition { return modules } +// GoAutoMenuGroups returns deep copies so callers cannot change global menu +// ordering while building migrations or permission trees. +func GoAutoMenuGroups() []MenuGroupDefinition { + groups := make([]MenuGroupDefinition, len(goAutoMenuGroupMetadata)) + copy(groups, goAutoMenuGroupMetadata) + for index := range groups { + groups[index].ModuleKeys = append([]string(nil), goAutoMenuGroupMetadata[index].ModuleKeys...) + } + return groups +} + func moduleKeyForAPI(path string) string { switch { case strings.HasPrefix(path, "/api/admin/v1/devices"): diff --git a/server/app/goauto/access/modules_test.go b/server/app/goauto/access/modules_test.go index 11579c3..498d4d7 100644 --- a/server/app/goauto/access/modules_test.go +++ b/server/app/goauto/access/modules_test.go @@ -61,6 +61,46 @@ func TestGoAutoMenuSortsFitMySQLSignedTinyInt(t *testing.T) { } } +func TestGoAutoMenuGroupsCoverModulesExactlyOnce(t *testing.T) { + groups := GoAutoMenuGroups() + if len(groups) != 2 { + t.Fatalf("got %d groups, want 2", len(groups)) + } + if groups[0].Title != "采集采购" || groups[1].Title != "采采管理" { + t.Fatalf("unexpected group titles: %#v", groups) + } + + wantOrder := [][]string{ + {ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks}, + {ModuleSYBShops, ModuleCollectionRules, ModuleDevices, ModuleAIMatching}, + } + seen := make(map[string]int) + for groupIndex, group := range groups { + if group.Sort < 0 || group.Sort > 127 { + t.Fatalf("group %s sort %d exceeds MySQL signed TINYINT", group.Key, group.Sort) + } + if len(group.ModuleKeys) != len(wantOrder[groupIndex]) { + t.Fatalf("group %s modules = %#v, want %#v", group.Key, group.ModuleKeys, wantOrder[groupIndex]) + } + for index, key := range group.ModuleKeys { + if key != wantOrder[groupIndex][index] { + t.Fatalf("group %s module %d = %s, want %s", group.Key, index, key, wantOrder[groupIndex][index]) + } + seen[key]++ + } + } + for _, module := range GoAutoModules() { + if seen[module.Key] != 1 { + t.Fatalf("module %s belongs to %d groups, want exactly one", module.Key, seen[module.Key]) + } + } + + groups[0].ModuleKeys[0] = "mutated" + if GoAutoMenuGroups()[0].ModuleKeys[0] != ModuleSYBProducts { + t.Fatal("GoAutoMenuGroups must return deep copies") + } +} + func TestGoAutoRoutesAreGeneratedFromServerMenus(t *testing.T) { _, fileName, _, ok := runtime.Caller(0) if !ok { diff --git a/server/cmd/migrate/migration/version-local/1787885600000_goauto_menu_groups.go b/server/cmd/migrate/migration/version-local/1787885600000_goauto_menu_groups.go new file mode 100644 index 0000000..ddbb8f2 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1787885600000_goauto_menu_groups.go @@ -0,0 +1,117 @@ +package version_local + +import ( + "fmt" + "runtime" + + "go-admin/app/goauto/access" + "go-admin/cmd/migrate/migration" + migrationmodels "go-admin/cmd/migrate/migration/models" + common "go-admin/common/models" + + "gorm.io/gorm" +) + +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateGoAutoMenuGroups) +} + +func migrateGoAutoMenuGroups(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := ensureGoAutoMenuGroups(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} + +func ensureGoAutoMenuGroups(db *gorm.DB) error { + modules := make(map[string]access.ModuleDefinition) + for _, module := range access.GoAutoModules() { + modules[module.Key] = module + } + + var purchaser migrationmodels.SysRole + if err := db.Where("role_key = ?", access.RolePurchaser).First(&purchaser).Error; err != nil { + return err + } + + allowedPurchaserMenus := make([]migrationmodels.SysMenu, 0, len(access.GoAutoModules())+len(access.GoAutoMenuGroups())) + for _, group := range access.GoAutoMenuGroups() { + groupMenu, _, err := upsertGoAutoMenu(db, migrationmodels.SysMenu{ + MenuName: group.MenuName, + Title: group.Title, + Icon: group.Icon, + Path: group.Path, + MenuType: "M", + Action: "无", + ParentId: 0, + Component: "Layout", + Sort: group.Sort, + Visible: "0", + IsFrame: "1", + }) + if err != nil { + return err + } + if err := db.Model(&groupMenu).Update("paths", fmt.Sprintf("/0/%d", groupMenu.MenuId)).Error; err != nil { + return err + } + allowedPurchaserMenus = append(allowedPurchaserMenus, groupMenu) + + for childIndex, moduleKey := range group.ModuleKeys { + module, ok := modules[moduleKey] + if !ok { + return fmt.Errorf("GoAuto menu group %s references unknown module %s", group.Key, moduleKey) + } + var child migrationmodels.SysMenu + if err := db.Unscoped().Where("menu_name = ?", module.RouteName).First(&child).Error; err != nil { + return fmt.Errorf("find GoAuto child menu %s: %w", module.RouteName, err) + } + updates := map[string]any{ + "parent_id": groupMenu.MenuId, + "paths": fmt.Sprintf("/0/%d/%d", groupMenu.MenuId, child.MenuId), + "sort": childIndex + 1, + "deleted_at": nil, + } + if err := db.Unscoped().Model(&child).Updates(updates).Error; err != nil { + return err + } + if module.PurchaserHardHidden { + if err := db.Model(&purchaser).Association("SysMenu").Delete(&child); err != nil { + return err + } + } else if module.PurchaserDefault { + allowedPurchaserMenus = append(allowedPurchaserMenus, child) + } + } + } + + if err := db.Model(&purchaser).Association("SysMenu").Append(allowedPurchaserMenus); err != nil { + return err + } + + oldRootNames := make([]string, 0, len(access.GoAutoModules())) + for _, module := range access.GoAutoModules() { + oldRootNames = append(oldRootNames, module.RouteName+"Root") + } + var oldRoots []migrationmodels.SysMenu + if err := db.Unscoped().Where("menu_name IN ?", oldRootNames).Find(&oldRoots).Error; err != nil { + return err + } + if len(oldRoots) == 0 { + return nil + } + oldRootIDs := make([]int, 0, len(oldRoots)) + for _, root := range oldRoots { + oldRootIDs = append(oldRootIDs, root.MenuId) + } + if err := db.Table("sys_role_menu").Where("menu_id IN ?", oldRootIDs).Delete(nil).Error; err != nil { + return err + } + if err := db.Table("sys_menu_api_rule").Where("sys_menu_menu_id IN ?", oldRootIDs).Delete(nil).Error; err != nil { + return err + } + return db.Unscoped().Where("menu_id IN ?", oldRootIDs).Delete(&migrationmodels.SysMenu{}).Error +} diff --git a/server/cmd/migrate/migration/version-local/1787885600000_goauto_menu_groups_test.go b/server/cmd/migrate/migration/version-local/1787885600000_goauto_menu_groups_test.go new file mode 100644 index 0000000..848db62 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1787885600000_goauto_menu_groups_test.go @@ -0,0 +1,112 @@ +package version_local + +import ( + "fmt" + "testing" + + "go-admin/app/goauto/access" + migrationmodels "go-admin/cmd/migrate/migration/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestEnsureGoAutoMenuGroups(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate( + &migrationmodels.SysRole{}, &migrationmodels.SysMenu{}, &migrationmodels.SysApi{}, + &purchaserCasbinRule{}, + ); err != nil { + t.Fatal(err) + } + custom := migrationmodels.SysMenu{MenuName: "UserCustomMenu", Title: "用户自定义菜单", Path: "/custom"} + if err = db.Create(&custom).Error; err != nil { + t.Fatal(err) + } + if err = ensureGoAutoMenusAndPurchaserBindings(db); err != nil { + t.Fatal(err) + } + + childIDs := make(map[string]int) + for _, module := range access.GoAutoModules() { + var child migrationmodels.SysMenu + if err = db.Where("menu_name = ?", module.RouteName).First(&child).Error; err != nil { + t.Fatal(err) + } + childIDs[module.RouteName] = child.MenuId + } + + if err = ensureGoAutoMenuGroups(db); err != nil { + t.Fatal(err) + } + assertGoAutoGroupedMenuState(t, db, childIDs) + if err = ensureGoAutoMenuGroups(db); err != nil { + t.Fatalf("group reconciliation must be repeatable: %v", err) + } + assertGoAutoGroupedMenuState(t, db, childIDs) + + var customAfter migrationmodels.SysMenu + if err = db.First(&customAfter, custom.MenuId).Error; err != nil { + t.Fatal("migration must not remove a user menu:", err) + } +} + +func assertGoAutoGroupedMenuState(t *testing.T, db *gorm.DB, childIDs map[string]int) { + t.Helper() + modules := make(map[string]access.ModuleDefinition) + for _, module := range access.GoAutoModules() { + modules[module.Key] = module + var oldRootCount int64 + if err := db.Unscoped().Model(&migrationmodels.SysMenu{}). + Where("menu_name = ?", module.RouteName+"Root").Count(&oldRootCount).Error; err != nil { + t.Fatal(err) + } + if oldRootCount != 0 { + t.Fatalf("old root %s still exists", module.RouteName+"Root") + } + } + + var purchaser migrationmodels.SysRole + if err := db.Where("role_key = ?", access.RolePurchaser).First(&purchaser).Error; err != nil { + t.Fatal(err) + } + for _, group := range access.GoAutoMenuGroups() { + var parent migrationmodels.SysMenu + if err := db.Where("menu_name = ?", group.MenuName).First(&parent).Error; err != nil { + t.Fatal(err) + } + if parent.ParentId != 0 || parent.Title != group.Title || parent.Path != group.Path || parent.Paths != fmt.Sprintf("/0/%d", parent.MenuId) { + t.Fatalf("unexpected group menu: %#v", parent) + } + assertRoleHasMenu(t, db, purchaser.RoleId, parent.MenuId, true) + + for index, moduleKey := range group.ModuleKeys { + module := modules[moduleKey] + var child migrationmodels.SysMenu + if err := db.Preload("SysApi").Where("menu_name = ?", module.RouteName).First(&child).Error; err != nil { + t.Fatal(err) + } + if child.MenuId != childIDs[module.RouteName] { + t.Fatalf("child %s ID changed from %d to %d", module.RouteName, childIDs[module.RouteName], child.MenuId) + } + if child.ParentId != parent.MenuId || child.Paths != fmt.Sprintf("/0/%d/%d", parent.MenuId, child.MenuId) || child.Sort != index+1 { + t.Fatalf("unexpected grouped child %s: %#v", module.RouteName, child) + } + if child.Path != module.Path+"/index" || child.Component != module.Component || len(child.SysApi) != len(module.APIs) { + t.Fatalf("route or API association changed for %s: %#v", module.RouteName, child) + } + assertRoleHasMenu(t, db, purchaser.RoleId, child.MenuId, !module.PurchaserHardHidden) + } + } + + var goAutoCount int64 + if err := db.Model(&migrationmodels.SysMenu{}).Where("menu_name LIKE ?", "GoAuto%").Count(&goAutoCount).Error; err != nil { + t.Fatal(err) + } + if want := int64(len(access.GoAutoModules()) + len(access.GoAutoMenuGroups())); goAutoCount != want { + t.Fatalf("got %d GoAuto rows, want %d", goAutoCount, want) + } +} diff --git a/web/tests/e2e/goauto-menu-permission.spec.ts b/web/tests/e2e/goauto-menu-permission.spec.ts index cda9269..0844214 100644 --- a/web/tests/e2e/goauto-menu-permission.spec.ts +++ b/web/tests/e2e/goauto-menu-permission.spec.ts @@ -14,26 +14,52 @@ const modules = [ ['/ai-matching-settings', '/goauto/ai-matching-settings/index', 'GoAutoAiMatchingSettings', 'AI 规格匹配', 'setting'] ] as const -function menu(module: typeof modules[number]) { +const menuGroups = [ + { + path: '/collection-purchase', + name: 'GoAutoCollectionPurchase', + title: '采集采购', + icon: 'shopping', + modules: [modules[3], modules[5], modules[6], modules[2], modules[1], modules[8], modules[9]] + }, + { + path: '/collection-management', + name: 'GoAutoCollectionManagement', + title: '采采管理', + icon: 'system', + modules: [modules[4], modules[7], modules[0], modules[10]] + } +] as const + +function childMenu(module: typeof modules[number]) { const [path, component, name, title, icon] = module return { - path, - component: 'Layout', + path: `${path}/index`, + component, visible: '0', - menuName: `${name}Root`, + menuName: name, title, - icon, - children: [{ - path: 'index', - component, - visible: '0', - menuName: name, - title, - icon - }] + icon } } +function groupedMenus(visibleModules: typeof modules[number][]) { + const allowed = new Set(visibleModules.map(module => module[2])) + return menuGroups.flatMap(group => { + const children = group.modules.filter(module => allowed.has(module[2])).map(childMenu) + if (children.length === 0) return [] + return [{ + path: group.path, + component: 'Layout', + visible: '0', + menuName: group.name, + title: group.title, + icon: group.icon, + children + }] + }) +} + async function mockSession(page: any, role: 'admin' | 'purchaser', visibleModules: typeof modules[number][]) { await page.context().addCookies([{ name: 'Admin-Token', @@ -48,28 +74,37 @@ async function mockSession(page: any, role: 'admin' | 'purchaser', visibleModule 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: groupedMenus(visibleModules) } }) } return route.fulfill({ json: { code: 200, data: [] } }) }) } -test('管理员菜单由服务端返回并包含全部 GoAuto 模块', async ({ page }) => { +test('管理员深链接展开所属分组并保留全部 GoAuto 页面', async ({ page }) => { await mockSession(page, 'admin', [...modules]) - await page.goto('/#/dashboard') + await page.goto('/#/pdd-products/index') - for (const module of modules) { - await expect(page.locator('.sidebar-container').getByText(module[3], { exact: true }).first()).toBeVisible() - } + await expect(page).toHaveURL(/#\/pdd-products\/index$/) + await expect(page.locator('.sidebar-container').getByText('采集采购', { exact: true }).first()).toBeVisible() + await expect(page.locator('.sidebar-container').getByText('采采管理', { exact: true }).first()).toBeVisible() + await expect(page.locator('.sidebar-container').getByText('PDD 商品', { exact: true }).first()).toBeVisible() + await expect(page.locator('.sidebar-container .is-active').getByText('PDD 商品', { exact: true })).toBeVisible() + + await page.goto('/#/ai-matching-settings/index') + await expect(page).toHaveURL(/#\/ai-matching-settings\/index$/) + await expect(page.locator('.sidebar-container').getByText('AI 规格匹配', { exact: true }).first()).toBeVisible() + await expect(page.locator('.sidebar-container .is-active').getByText('AI 规格匹配', { exact: true })).toBeVisible() }) test('采购员只看到授权菜单且不能通过地址访问 AI 配置', async ({ page }) => { - const purchaserModules = [modules[4], modules[9]] + const purchaserModules = modules.filter(module => module[2] !== 'GoAutoAiMatchingSettings') await mockSession(page, 'purchaser', purchaserModules) - await page.goto('/#/dashboard') + await page.goto('/#/devices/index') + await expect(page.locator('.sidebar-container').getByText('采集采购', { exact: true }).first()).toBeVisible() + await expect(page.locator('.sidebar-container').getByText('采采管理', { exact: true }).first()).toBeVisible() 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('设备列表', { exact: true }).first()).toBeVisible() await expect(page.locator('.sidebar-container').getByText('AI 规格匹配', { exact: true })).toHaveCount(0) await page.goto('/#/ai-matching-settings/index')