AI 生成 Vue3 后台后状态乱了?Pinia 双树和 Go 权限联动这样查

📅 2026/7/19 7:12:37 👁️ 阅读次数 📝 编程学习
AI 生成 Vue3 后台后状态乱了?Pinia 双树和 Go 权限联动这样查

Cursor 或 Agent 生成 Vue3 后台时,最容易让人误判的一点是:页面能跑,不等于状态是干净的。登录态、菜单树、按钮权限、通知、WebSocket、用户资料,如果被分散到两套 Pinia 目录里,短期看只是“偶尔刷新丢菜单”,上线后就会变成权限错乱:A 角色看到了 B 角色的菜单,按钮隐藏了但接口还能打,刷新后前端状态和 Go 后端返回的权限又对不上。

这篇不讨论 AI 会不会替代前端,也不写工具清单。只看一个能落地的排查问题:AI 生成或重构 Vue3 管理后台后,怎么检查 Pinia 状态树、动态路由和 Go 后台 RBAC 是否还在同一套规则里。

先判断是不是“状态树分叉”

很多后台项目会有类似目录:

src/ store/ modules/ user.ts permission.ts app.ts stores/ backend/ chat.ts notification.ts websocket.ts

这不一定马上报错。真正的问题是两个目录可能各自维护登录态、菜单、权限点或连接状态。AI 重构时尤其容易这样做:它看到老目录就沿用老目录,看到新模块又新增一套模块,最后页面上能 import 成功,但状态来源已经不唯一。

可以先用下面几个命令做静态检查:

# 1. 找出项目里所有 Pinia store 定义 rg "defineStore\(" src # 2. 找出谁在读取菜单和按钮权限 rg "menu|menus|permission|permissions|button|buttons" src/store src/stores src/router src/views # 3. 找出 localStorage / sessionStorage 里是否保存了重复权限字段 rg "localStorage|sessionStorage" src

如果结果里同时出现 `src/store/modules/permission.ts` 和 `src/stores/backend/*`,就要继续查这些模块是否互相隔离。后台系统里最怕的不是多几个文件,而是同一个“当前用户权限”被拆成两份。

一个常见故障:菜单刷新后变了,接口权限没变

假设后端给当前用户返回这样的菜单和接口权限:

{ "userId": 1001, "roleKeys": ["operator"], "menus": [ {"name": "UserList", "path": "/system/user", "type": "menu"}, {"name": "UserQuery", "path": "GET /admin/user/list", "type": "api"} ], "buttons": ["system:user:query"] }

前端如果只拿 `menus` 生成动态路由,却把 `buttons` 放到另一套 store,按钮权限就容易和路由权限脱节。页面上可能看不到“删除用户”按钮,但某个旧组件还从旧 store 里读到了 `system:user:delete`,或者接口层没有用后端 RBAC 再拦一次。

上线前至少跑这三类验证:

# 菜单接口:当前角色只能看到用户列表 curl -s 'http://127.0.0.1:8000/admin/user/menu' \ -H 'Authorization: Bearer <operator-token>' | jq '.data.menus' # 允许的查询接口应返回 200 curl -i 'http://127.0.0.1:8000/admin/user/list' \ -H 'Authorization: Bearer <operator-token>' # 不允许的删除接口必须返回 403 curl -i -X DELETE 'http://127.0.0.1:8000/admin/user/delete?id=1001' \ -H 'Authorization: Bearer <operator-token>'

如果第三个请求返回 200,前端 store 再整齐也没用。按钮隐藏只能改善交互,不能替代后端接口鉴权。

Pinia 侧怎么查:每个权限字段只能有一个来源

我通常会把检查点压到很具体,别只看“页面有没有报错”。

// src/store/modules/permission.ts import { defineStore } from 'pinia' import { getUserMenus } from '@/api/system/permission' export const usePermissionStore = defineStore('permission', { state: () => ({ routes: [] as AppRouteRecordRaw[], buttons: [] as string[], apiPerms: [] as string[], loaded: false }), actions: { async loadPermission() { const res = await getUserMenus() this.routes = buildRoutes(res.data.menus) this.buttons = res.data.buttons || [] this.apiPerms = res.data.apiPerms || [] this.loaded = true }, hasButton(code: string) { return this.buttons.includes(code) }, hasApi(perm: string) { return this.apiPerms.includes(perm) } } })

关键不是这段代码长什么样,而是它要成为唯一入口。组件里不要再到处写:

const buttons = JSON.parse(localStorage.getItem('buttons') || '[]') const menus = userStore.userInfo.menus const oldPerms = backendStore.permissionList

这种写法短期最省事,也最容易把 AI 生成的补丁变成隐患。建议用搜索把这些旧入口删干净:

rg "permissionList|userInfo\.menus|localStorage\.getItem\('buttons'\)|backendStore" src

每命中一处,就确认它是不是仍在参与权限判断。不是的话删掉;是的话迁到统一 store。

Go 后端也要给前端一个稳定契约

前端状态能不能稳定,取决于后端接口返回是否稳定。不要让菜单接口今天返回 `buttonCodes`,明天返回 `permissions`,后天又让前端从角色详情里拼。

一个比较清楚的返回结构可以这样定:

type UserPermissionRes struct { UserId uint64 `json:"userId"` RoleKeys []string `json:"roleKeys"` Menus []MenuItem `json:"menus"` Buttons []string `json:"buttons"` ApiPerms []string `json:"apiPerms"` } type MenuItem struct { Name string `json:"name"` Path string `json:"path"` ParentId uint64 `json:"parentId"` Type string `json:"type"` // menu / button / api }

接口层再用同一套 `ApiPerms` 做校验:

func PermissionMiddleware(r *ghttp.Request) { user := contexts.GetUser(r.Context()) if user == nil { r.Response.WriteStatusExit(401) return } perm := strings.ToUpper(r.Method) + " " + r.URL.Path ok, err := service.Permission().UserCanAccess(r.Context(), user.Id, perm) if err != nil { g.Log().Warningf(r.Context(), "permission check failed: %v", err) r.Response.WriteStatusExit(500) return } if !ok { r.Response.WriteStatusExit(403) return } r.Middleware.Next() }

这样前端只负责“显示什么”,后端负责“能不能做”。两边用同一份权限语义,但不要互相替代。

数据库里也能查出不一致

如果怀疑菜单、按钮、接口没有绑在一起,可以直接从库里查。下面用一组中性表名示例:

-- 查某个角色拥有的菜单和按钮 SELECT r.role_key, m.id, m.title, m.type, m.permission_code, m.api_perm FROM admin_role r JOIN admin_role_menu rm ON rm.role_id = r.id JOIN admin_menu m ON m.id = rm.menu_id WHERE r.role_key = 'operator' ORDER BY m.parent_id, m.id; -- 查有接口权限字段但没有挂给任何角色的动作 SELECT m.id, m.title, m.api_perm FROM admin_menu m LEFT JOIN admin_role_menu rm ON rm.menu_id = m.id WHERE m.api_perm <> '' AND rm.menu_id IS NULL; -- 查按钮权限存在,但缺少后端接口映射的记录 SELECT id, title, permission_code FROM admin_menu WHERE type = 'button' AND (api_perm IS NULL OR api_perm = '');

这三条 SQL 很适合放进上线前检查。尤其是第三条:按钮权限如果没有接口映射,前端很可能“看起来管住了”,后端却没有真正拦住。

AI 重构后再跑一次构建和权限冒烟

状态树分叉经常不是 TypeScript 第一时间报错,而是在运行时暴露。可以把检查步骤固定下来:

# 前端类型和构建 pnpm typecheck pnpm build # 后端测试或最小启动 go test ./... go run main.go # 权限冒烟:普通角色不能访问管理员动作 curl -i -X DELETE 'http://127.0.0.1:8000/admin/user/delete?id=1' \ -H 'Authorization: Bearer <operator-token>'

日志里至少要能看到权限被拒绝的原因,而不是只有一个 500:

WARN permission denied user_id=1001 role=operator perm="DELETE /admin/user/delete" HTTP/1.1 403 Forbidden

如果这里打出 500,说明权限异常和业务异常混在一起了。后面排查会很痛苦。

什么时候该用代码生成器约束 AI

AI 适合补页面、补字段、补测试草稿,但后台项目里的确定性最好别全靠提示词。表结构、接口路径、菜单权限、按钮编码、前端 store 入口,这些东西越早固定,后面越少返工。

XYGo Admin 最近一次提交里做过一个很典型的维护动作:把 `stores/backend` 下的 chat、notification、websocket 迁到统一的 `store/modules`,同时修前端 TypeScript 配置。这类改动不炫,但它说明一个现实问题:后台项目跑久以后,真正消耗时间的往往不是再生成一个 CRUD,而是把状态、路由、权限、类型这些边界收回来。项目源码可以看这里:`GitHub - z312193608/xygo-admin: Open-source admin framework built with GoFrame v2 and Vue 3, featuring RBAC, full-stack CRUD code generation, MySQL/PostgreSQL, plugins, and single-binary deployment. · GitHub`。

我的建议是:让 AI 写代码之前,先把生成规则和验收表写清楚。比如:

1. 新模块必须使用 src/store/modules 下的 Pinia store 2. 菜单、按钮、接口权限必须来自同一个权限接口 3. 前端隐藏按钮后,后端 DELETE/POST 接口仍要返回 403 4. 新增字段后必须同步表单、列表、查询条件和导入导出 5. 构建、类型检查、curl 权限冒烟全部通过后才合并

这比“帮我生成一个后台管理模块”有效得多。AI 可以很快把文件铺出来,但能不能上线,最后还是看这些边界有没有被验过。

再补一层字段同步检查

状态树收拢以后,还要看 CRUD 二次生成有没有把字段同步完整。AI 很容易只改表单,不改列表;只改前端类型,不改 Go 入参;只改数据库字段,不补导入导出。结果就是页面能保存,搜索条件却查不到,或者导入模板里还是旧字段。

可以从数据库开始对字段做一次对账。假设新增了 `mobile`、`status`、`remark` 这几个字段,先确认表结构里真实存在:

SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = 'admin_user' AND column_name IN ('mobile', 'status', 'remark') ORDER BY ordinal_position;

再检查后端请求结构体有没有同步:

rg "type .*Req struct|Mobile|Status|Remark" internal api app

前端也要查四个地方:列表列、表单项、搜索条件、导入导出字段。不要只看页面上有没有输入框。

rg "mobile|status|remark" src/views src/api src/types

如果你用的是生成器,二次同步按钮或命令应该至少更新这些文件:

api 请求类型 Go 入参/出参结构体 service/logic 保存逻辑 Vue 列表列配置 Vue 表单字段 搜索条件 导入导出模板 权限按钮编码

这里最容易出错的是“旧字段还在参与搜索”。比如前端发了 `phone`,后端实际只认 `mobile`,接口不一定报错,只是查不到数据。上线前用一条请求把它打出来:

curl -G 'http://127.0.0.1:8000/admin/user/list' \ -H 'Authorization: Bearer <admin-token>' \ --data-urlencode 'mobile=13800000000' \ --data-urlencode 'status=1'

日志里应该能看到最终 SQL 使用的是新字段:

SELECT * FROM admin_user WHERE mobile = ? AND status = ? LIMIT 10

如果日志里还是 `phone = ?`,说明生成后的某一层没有同步。这个问题和 AI 关系不大,手写也会犯;AI 只是把文件生成得更快,让这种不一致更不容易被肉眼发现。

发布前检查表

最后给一份可以直接复制的检查表:

[ ] 项目里只有一套 Pinia 权限入口 [ ] 动态路由、按钮权限、接口权限来自同一个后端响应 [ ] localStorage 里没有旧权限字段继续参与判断 [ ] 普通角色访问管理员接口返回 403 [ ] SQL 能查出未绑定角色的接口权限 [ ] 新增字段后列表、表单、搜索、导入导出同步更新 [ ] pnpm typecheck / pnpm build / go test ./... 通过 [ ] 权限拒绝日志能定位 user、role 和 perm

如果你正在用 Cursor 或 Agent 生成 GoFrame + Vue3 后台,建议先跑这张表。页面生成得快是好事,但后台系统真正怕的是“看起来能用,权限和状态其实各走各的”。