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

权限问题排查最怕日志只有"请求成功"。至少把这几个字段打出来:

复制代码
time=2026-07-21T11:30:00+08:00 level=warn
path=/admin/user/delete method=POST
user_id=18 tenant_id=tenant_a permission=user:delete
status=403 reason=permission_denied request_id=req_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 和导出日志都跑过,才算权限验收开始有底。

相关推荐
用户8356290780511 小时前
Python 实现 Excel 页面布局与打印设置自动化
后端·python
用户9931441579841 小时前
微服务框架中获取用户信息
后端
এ慕ོ冬℘゜1 小时前
前端基础:什么是时间戳?JS获取时间戳三种方法与实战用途
开发语言·前端·javascript
执明wa1 小时前
LayoutInflater详解: XML是如何变成View的?
android·xml·开发语言·android studio
xuanWb1 小时前
手写一个 LLM API 网关:Anthropic 与 OpenAI 协议转换的完整实现
后端
苍何2 小时前
给 Codex 换皮肤这门生意,被我开源了
后端
用户8356290780512 小时前
Python 实现 Excel 命名范围(Named Range)的创建与管理
后端·python
程序员David2 小时前
我让 Claude 从架构文档一路干到代码,踩了三个坑才摸清边界
后端
Zane19942 小时前
并发 vs 并行:别再傻傻分不清了,一文讲透 Java 并发编程的第一课
java·后端