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

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐