Cursor 生成 CRUD 后,Go 后台接口别只测 200:JWT、RBAC 和 tenant_id 怎么验

Cursor 生成 CRUD 后,Go 后台接口别只测 200:JWT、RBAC 和 tenant_id 怎么验 AI 或 Cursor 把 CRUD 接口生成出来之后最危险的错觉不是“代码不能跑”而是“页面能点、接口返回 200于是大家以为权限也过了”。后台权限真出问题时往往不是列表页白屏而是一个没有菜单权限的账号还能直接调接口或者另一个租户的数据从导出任务里漏出来。我现在更愿意把验收拆成三条线JWT 只证明“你是谁”RBAC 证明“你能不能做这个动作”tenant_id 证明“这条数据是不是属于你”。三条线少一条AI 生成得再快也只是把返工提前藏进了代码里。下面用一个 Go 后台接口做例子代码可以直接改到自己的项目里。示例不依赖某个框架GoFrame、Gin、Echo 都能照这个思路验。先把风险写成接口矩阵别一上来就补页面按钮。先列接口尤其是导出、批量删除、详情、状态更新这些“页面上不显眼但数据权限很重”的接口。GET /admin/user/list 需要 user:list必须带 tenant_id GET /admin/user/detail/:id 需要 user:detail必须校验记录 tenant_id POST /admin/user/update 需要 user:update禁止改 tenant_id POST /admin/user/delete 需要 user:delete批量 id 也要逐条查租户 GET /admin/user/export 需要 user:export导出条件必须带 tenant_id这个矩阵有两个用处。第一给 AI 或代码生成器明确边界不要只说“生成用户管理 CRUD”。第二后面写测试时可以逐项打勾不靠肉眼看页面。SQL 先查三类漏洞很多后台系统是先有单租户表后面才补多租户。补字段之后最容易漏的是唯一索引、历史脏数据和空 tenant_id。-- 1. 旧数据有没有缺 tenant_id SELECT id, username, tenant_id FROM admin_user WHERE tenant_id IS NULL OR tenant_id LIMIT 20; -- 2. 跨租户是否共用了本该隔离的唯一值 SELECT username, COUNT(*) AS cnt, COUNT(DISTINCT tenant_id) AS tenants FROM admin_user GROUP BY username HAVING cnt 1 AND tenants 1; -- 3. 索引是不是仍然只按 username 唯一 SHOW INDEX FROM admin_user WHERE Column_name IN (username, tenant_id);如果用户名允许不同租户重复唯一约束就不该只压在 username 上而要改成租户内唯一ALTER TABLE admin_user DROP INDEX uk_username, ADD UNIQUE KEY uk_tenant_username (tenant_id, username);PostgreSQL 也一样语义要写清楚CREATE UNIQUE INDEX uk_tenant_username ON admin_user (tenant_id, username);这里不要只看迁移脚本能不能执行。真正要问的是查询、更新、导出、缓存、日志有没有同步带上 tenant_id。Go 代码里不要到处手写 tenant_id临时写法通常长这样func UserList(ctx context.Context, req *UserListReq) ([]User, error) { tenantID : ctx.Value(tenant_id).(string) return userDao.Where(tenant_id, tenantID).Where(status, req.Status).All(ctx) }它能跑但后面每个接口都要靠人记得补。更稳一点的做法是把租户上下文和查询入口收起来type Tenant struct { ID string } func TenantFromContext(ctx context.Context) (Tenant, bool) { v : ctx.Value(tenant) t, ok : v.(Tenant) return t, ok t.ID ! } func WithTenant(ctx context.Context, q Query) (Query, error) { t, ok : TenantFromContext(ctx) if !ok { return q, errors.New(missing tenant) } return q.Where(tenant_id ?, t.ID), nil }业务代码只拿已经处理过租户条件的查询对象。这样做不华丽但很实用Review 时只要搜 WithTenant就能知道这个接口有没有进入租户边界。RBAC 不要和菜单显示混在一起前端菜单隐藏只是体验层。接口权限必须在后端再验一次否则用户拿到接口地址后照样可以打。func RequirePermission(code string) Middleware { return func(ctx context.Context, next Handler) error { claims, ok : ClaimsFromContext(ctx) if !ok { return HTTPError(401, missing token) } allowed, err : permissionRepo.Has(ctx, claims.RoleIDs, code) if err ! nil { return err } if !allowed { return HTTPError(403, permission denied) } return next(ctx) } }页面权限、按钮权限、接口权限最好用同一套权限码但不要只存在前端。比如 user:delete前端用它决定按钮显不显示后端也用它决定接口能不能进。用 curl 跑出 401、403、200这一步很土但比“我点了一下页面”可靠。# 1. 没有 token应该是 401 curl -i https://api.example.com/admin/user/list # 2. 有 token但角色没有 user:list应该是 403 curl -i -H Authorization: Bearer USER_WITHOUT_PERMISSION https://api.example.com/admin/user/list # 3. 有 token 且有权限应该是 200 curl -i -H Authorization: Bearer USER_WITH_PERMISSION https://api.example.com/admin/user/list再补一个跨租户请求。这个请求最容易被漏掉因为它通常不是权限码问题而是数据范围问题。curl -i -H Authorization: Bearer TENANT_A_ADMIN https://api.example.com/admin/user/detail/tenant-b-user-id期望结果不是 200。可以返回 404也可以返回 403团队内部定一种语义就行。关键是不能把 B 租户的数据返回给 A。导出接口要单独验不少系统列表接口带了 tenant_id导出接口却重新拼了一段 SQL。因为导出经常后写或者被当成“列表的附属功能”。我会单独查它-- 导出任务表里要记录租户和操作者 SELECT id, tenant_id, operator_id, export_type, created_at FROM export_task ORDER BY id DESC LIMIT 10;导出任务真正执行时也要把 tenant_id 带进查询func BuildUserExportSQL(tenantID string, filter UserFilter) (string, []any) { sql : SELECT id, username, mobile, created_at FROM admin_user WHERE tenant_id ? args : []any{tenantID} if filter.Keyword ! { sql AND username LIKE ? args append(args, %filter.Keyword%) } return sql, args }如果导出走异步队列队列消息里也要带 tenant_id不要只带一个 task_id 后再到数据库里“猜”。日志别只记 success权限问题排查最怕日志只有“请求成功”。至少把这几个字段打出来time2026-07-21T11:30:0008:00 levelwarn path/admin/user/delete methodPOST user_id18 tenant_idtenant_a permissionuser:delete status403 reasonpermission_denied request_idreq_7f31线上看到 403不一定是坏事。它可能说明后端拦住了不该进来的请求。真正要紧的是你能不能知道谁、在哪个租户、打了哪个接口、缺哪个权限。代码生成器要生成“验收点”不是只生成文件AI 和代码生成器生成 CRUD 没问题问题是生成结束后没人补边界。我的做法是让生成器输出接口时同时生成一份权限码和测试清单module: user permissions: - user:list - user:detail - user:create - user:update - user:delete - user:export checks: - no token 401 - missing permission 403 - tenant A cannot read tenant B data - export query includes tenant_id这也是我会拿 XYGo Admin 做对照的原因。它的 server/internal/middleware/admin_permission.go、server/internal/logic/gencodes/generate.go 和 web/src/router/core/RoutePermissionValidator.ts 分别对应后端权限、生成器和前端路由校验能说明一个判断生成 CRUD 和验权限边界是两件事不能混成一个“页面能用”。上线前我会留这张表| 检查项 | 期望结果 | 怎么验证 | |---|---|---| | 无 token 请求 | 401 | curl 不带 Authorization | | 无权限角色请求 | 403 | 用缺少权限码的账号 token | | 正常角色请求 | 200 | 用有权限码的账号 token | | 跨租户详情 | 403 或 404 | A 租户 token 请求 B 租户记录 | | 导出任务 | 只导出当前租户 | 查 SQL 和 export_task.tenant_id | | 缓存 key | 包含 tenant_id | 搜索 user:list 等缓存前缀 | | 日志字段 | 有 user_id、tenant_id、permission | 查接口访问日志 |写后台系统时AI 负责把重复代码打出来人负责把边界验清楚。不要把这件事反过来。页面跑通只能说明交互暂时没坏401、403、tenant_id 和导出日志都跑过才算权限验收开始有底。