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 后台,建议先跑这张表。页面生成得快是好事,但后台系统真正怕的是“看起来能用,权限和状态其实各走各的”。
更多推荐


所有评论(0)