Cursor 或别的 AI 工具把后台 CRUD 写出来以后,最危险的地方往往不是列表页,也不是新增编辑弹窗,而是导出、批量删除、批量改状态这类“看起来只是按钮”的接口。页面上按钮隐藏了,不代表接口不能被直接调用;菜单没显示,也不代表后端一定拦住了请求。
我更愿意把这类问题当成接口验收,而不是前端权限问题。前端最多减少误点,真正挡住越权的只能是后端权限码、租户条件、数据范围和日志。下面用一个客户列表导出接口举例,代码可以直接改成自己的模块名。
先看一个容易漏的表结构
假设后台里有客户表和管理员权限表,客户表里带租户字段,权限表里保存菜单、按钮或接口权限码:
CREATE TABLE crm_customer (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
name VARCHAR(80) NOT NULL,
mobile VARCHAR(32) DEFAULT '',
level TINYINT NOT NULL DEFAULT 1,
deleted_at DATETIME NULL,
created_at DATETIME NOT NULL,
KEY idx_tenant_deleted (tenant_id, deleted_at),
KEY idx_mobile (mobile)
);
CREATE TABLE admin_role_permission (
role_id BIGINT NOT NULL,
permission_code VARCHAR(120) NOT NULL,
PRIMARY KEY (role_id, permission_code)
);
很多 AI 生成的 CRUD 会把列表、详情、新增、编辑、删除都补齐,但导出接口经常被当成“复用列表查询”。这一步如果少了权限码,就会出现一个很尴尬的结果:页面按钮不可见,复制接口地址却能下载整张表。
导出接口不能只复用列表查询
列表接口通常长这样:
func (s *CustomerService) List(ctx context.Context, in ListReq) (*ListRes, error) {
tenantID := TenantIDFromCtx(ctx)
q := dao.CrmCustomer.Ctx(ctx).
Where("tenant_id", tenantID).
WhereNull("deleted_at")
if in.Keyword != "" {
q = q.WhereLike("name", "%"+in.Keyword+"%")
}
var rows []CustomerListItem
err := q.Page(in.Page, in.PageSize).Scan(&rows)
return &ListRes{Rows: rows}, err
}
导出接口当然可以复用查询条件,但它还要多做三件事:权限码检查、导出字段白名单、导出日志。少一个都容易出事。
func (s *CustomerService) Export(ctx context.Context, in ExportReq) error {
if err := RequirePermission(ctx, "crm:customer:export"); err != nil {
return err
}
tenantID := TenantIDFromCtx(ctx)
fields := normalizeExportFields(in.Fields)
if len(fields) == 0 {
fields = []string{"name", "mobile", "level", "created_at"}
}
q := dao.CrmCustomer.Ctx(ctx).
Where("tenant_id", tenantID).
WhereNull("deleted_at").
OrderDesc("created_at")
if in.Keyword != "" {
q = q.WhereLike("name", "%"+in.Keyword+"%")
}
var rows []CustomerExportRow
if err := q.Fields(fields).Limit(5000).Scan(&rows); err != nil {
return err
}
g.Log().Info(ctx, "customer_export", g.Map{
"tenant_id": tenantID,
"operator": OperatorIDFromCtx(ctx),
"fields": fields,
"count": len(rows),
})
return writeCustomerExcel(ctx, rows)
}
这里的重点不是 Go 语法,而是验收顺序:先查权限,再收租户,再收字段,最后写日志。很多“自动生成”的后台只做到了中间两步。
权限中间件要能拦住真实请求
如果项目里已经有统一中间件,导出接口最好走同一条链路,不要在 handler 里临时 if 一下。一个简化版中间件大概是这样:
func AdminPermissionMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
code := r.Context().Value("permission_code").(string)
uid := r.Context().Value("admin_id").(int64)
ok, err := permissionRepo.HasPermission(r.Context(), uid, code)
if err != nil {
writeJSON(w, 500, "permission check failed")
return
}
if !ok {
writeJSON(w, 403, "permission denied")
return
}
next.ServeHTTP(w, r)
})
}
实际项目会更复杂:超级管理员、数据范围、路由元信息、按钮权限码都要处理。但验收口径不变,导出接口必须能被同一套权限链拦住。
用 curl 把 401、403、200 都测出来
别只测一个正常账号。至少准备三个 token:未登录、已登录但没有导出权限、有导出权限。
# 1. 未登录,应该是 401
curl -i 'https://api.example.com/admin/crm/customer/export?keyword=a'
# 2. 有列表权限但没有导出权限,应该是 403
curl -i -H 'Authorization: Bearer $TOKEN_LIST_ONLY' 'https://api.example.com/admin/crm/customer/export?keyword=a'
# 3. 有导出权限,应该是 200,并且返回文件
curl -i -H 'Authorization: Bearer $TOKEN_EXPORT' 'https://api.example.com/admin/crm/customer/export?keyword=a'
期望日志也要看一眼:
INFO customer_export tenant_id=1001 operator=9003 fields=name,mobile,level count=128
WARN permission_denied tenant_id=1001 operator=9011 permission=crm:customer:export path=/admin/crm/customer/export
如果第二条请求返回 200,说明按钮隐藏只是前端效果,后端权限没闭环。这个问题在普通列表页不容易暴露,因为列表页本来就有权限;导出接口一旦漏掉,泄露的是批量数据。
字段白名单也要验
导出接口还有一个常见坑:前端传什么字段,后端就导什么字段。AI 生成表单时尤其容易这样写,因为它会把字段配置当成可信输入。
var allowedExportFields = map[string]bool{
"name": true,
"mobile": true,
"level": true,
"created_at": true,
}
func normalizeExportFields(fields []string) []string {
out := make([]string, 0, len(fields))
for _, f := range fields {
if allowedExportFields[f] {
out = append(out, f)
}
}
return out
}
再补一条夹带字段的请求:
curl -i -H 'Authorization: Bearer $TOKEN_EXPORT' 'https://api.example.com/admin/crm/customer/export?fields=name,mobile,password_hash'
预期结果不是报错也不是导出 `password_hash`,而是只保留白名单字段,并在日志里记录这次请求带过非法字段。很多后台事故不是权限模型没设计,而是边角接口没有按同一个模型验。
代码生成器应该生成验收点
如果团队已经在用代码生成器,我建议不要只让它生成 controller、service、vue 页面。更有价值的是顺手生成下面这些验收项:
| 接口 | 权限码 | 必测结果 |
|---|---|---|
| 列表 | crm:customer:list | 401、403、200 |
| 新增 | crm:customer:create | 401、403、200 |
| 编辑 | crm:customer:update | 401、403、200 |
| 删除 | crm:customer:delete | 401、403、200 |
| 导出 | crm:customer:export | 401、403、200、字段白名单、日志 |
我维护 XYGo Admin 时也会把这个问题放回生成器和权限中间件里看:`server/internal/middleware/admin_permission.go` 负责权限链路,`server/internal/logic/gencodes/generate.go` 负责生成器主流程,Issue #3 里提到的字段标签化也会影响搜索、必填和导出字段的语义。它不是让你照搬项目,而是给一个 GoFrame 后台里“生成器 + RBAC + 字段语义”怎么落到源码的样本:GitHub 仓库。
发布前检查清单
最后给一份我会放进 PR 里的检查清单:
[ ] 导出接口有独立权限码,不复用 list 权限
[ ] 后端中间件能返回 401 / 403 / 200 三种结果
[ ] 查询条件包含 tenant_id 或数据范围
[ ] 导出字段走后端白名单,不信前端传参
[ ] 批量操作、导出、删除都有结构化日志
[ ] 代码生成器输出接口清单和 curl 验收命令
Cursor 写完 CRUD 以后,最该补的不是“再美化一下页面”,而是把这些边界测完。页面看起来能用,只说明第一版出来了;导出接口也能被权限、租户、字段白名单和日志一起收住,才算后台能交付。

2103

被折叠的 条评论
为什么被折叠?



