签入版本
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
# 5.18 gmeta 注解系统总览
|
||||
|
||||
:::tip 概述
|
||||
gmeta 注解是本项目的"声明即事实"机制:请求结构体上的一个 gmeta.Meta 标签同时驱动路由注册、权限校验、操作日志、OpenAPI 文档四件事。解析与分发在 internal/router/bind.go(反射绑定)与 meta/(路由元数据表)。
|
||||
:::
|
||||
|
||||
## 一个标签,四份事实
|
||||
|
||||
```go
|
||||
type UserPageReq struct {
|
||||
gmeta.Meta `method:"get" path:"/api/user/page" summary:"用户分页"
|
||||
permission:"sys:user:page" log:"用户管理-查询分页记录" logType:"query"`
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
```text
|
||||
解析后同时产出:
|
||||
① 路由注册 GET /api/user/page → CUser.Page
|
||||
② 权限校验 HasPerm("sys:user:page")(包装层每请求判定)
|
||||
③ 操作日志 标题"用户管理-查询分页记录"、类型 query
|
||||
④ OpenAPI 文档 /api.json 的 path/summary/参数说明
|
||||
```
|
||||
|
||||
不存在"代码改了忘改路由表/权限表/文档"——标签是唯一真相。
|
||||
|
||||
## 标签字段语义
|
||||
|
||||
| 标签 | 语义 |
|
||||
|------|------|
|
||||
| `method` | HTTP 方法(get/post/put/delete) |
|
||||
| `path` | 完整路由模板(含 /api 前缀;`:id` 为路由参数,req 同名字段承接) |
|
||||
| `summary` | OpenAPI 摘要 |
|
||||
| `permission` | 权限节点 `sys:{module小驼峰}:{action}`;空 = 登录即可 |
|
||||
| `log` | 操作日志标题 `"{模块中文名}-{动作}"`;空 = 不记录 |
|
||||
| `logType` | 日志类型字符串(query/add/update/delete/import/export/reset/…),经 LogTypeMap 转 41 个枚举值 |
|
||||
| `public` | `"true"` = 免登录(仍需与 middleware/public.go 白名单一致) |
|
||||
|
||||
## Bind 绑定流程(internal/router/bind.go)
|
||||
|
||||
```text
|
||||
Bind(s, new(controller.CXxx)) 扫描控制器全部导出方法:
|
||||
1) 校验签名:func (c *Xxx) Page(ctx, req *v1.XxxReq) (*v1.XxxRes, error)
|
||||
(签名不符静默跳过——排障注意)
|
||||
2) 解析请求结构体内嵌 gmeta.Meta 扩展标签
|
||||
3) 按 method:path 注册路由 + meta.Register(rm) 写入路由元数据表
|
||||
(键 "METHOD /path",读写锁保护;Lookup 供调试,All 供文档生成)
|
||||
4) 补注册进 gf goai(闭包路由 gf 不会自动收集)→ /api.json 原生 OpenAPI
|
||||
5) 生成统一包装 handler:
|
||||
路由元数据写入 ctx(操作日志中间件读取)
|
||||
→ 权限校验(403 短路;不设独立 Permission 中间件)
|
||||
→ r.Parse(req)(路由参数+query+body,含 gvalid 校验)
|
||||
→ 调用业务方法 → response.Ok/Fail 信封写出
|
||||
```
|
||||
|
||||
## BodyUnmarshaler 逃生舱
|
||||
|
||||
```text
|
||||
当请求体顶层形态无法用结构体标签表达(如平铺 map)时,
|
||||
Req 实现 UnmarshalBody(r *ghttp.Request) error 接口:
|
||||
→ 跳过默认 Parse,实现内自行完成解析与 gvalid 校验
|
||||
```
|
||||
|
||||
## 注册验证
|
||||
|
||||
```sh
|
||||
# OpenAPI 检查(gmeta 补注册后全量可用)
|
||||
curl http://127.0.0.1:8000/api.json | grep "user/page"
|
||||
|
||||
# 启动路由表
|
||||
# config.yaml: server.dumpRouterMap: true
|
||||
```
|
||||
|
||||
## 开发规约
|
||||
|
||||
```text
|
||||
* 每个接口必须有 gmeta——没有标签的方法不会注册路由
|
||||
* permission 新码必须同步菜单节点 + 角色勾选(否则 403)
|
||||
* public:"true" 必须同时出现在 middleware/public.go 白名单(三处一致)
|
||||
* log 空 = 不记录(有意不记录才留空);logType 缺省按 HTTP 方法自动推断(0)
|
||||
* path 一律 /api 开头、无 /v1 版本段
|
||||
* 改 path/permission 等标签后重启生效(路由与元数据表启动期构建)
|
||||
```
|
||||
@@ -0,0 +1,109 @@
|
||||
# 5.2 认证流程实战
|
||||
|
||||
:::tip 概述
|
||||
本节以登录闭环为主线,串起验证码、用户查找、状态校验、密码比对、令牌签发、登录日志、在线登记八个步骤,给出完整时序图与失败分支处理。对应代码在 internal/logic/auth.go。
|
||||
:::
|
||||
|
||||
## 登录时序图
|
||||
|
||||
```text
|
||||
Client Server/auth Redis DB
|
||||
│ GET /api/captcha │ │ │
|
||||
│────────────────────▶│ Generate ─────────▶│ SET captcha:uuid│
|
||||
│◀── {key, 图片base64} │ │ │
|
||||
│ POST /api/login {username,password,code,key,tenantCode?}
|
||||
│────────────────────▶│ Verify ───────────▶│ GET+DEL captcha:uuid
|
||||
│ │ findUser ──────────────────────────▶│ user(+tenant)
|
||||
│ │ status==1? mustLoadTenant ────────▶│ tenant(60s缓存)
|
||||
│ │ bcrypt(password+salt) 比对 │
|
||||
│ │ Sign(access) + Sign(refresh) │
|
||||
│ │ writeLoginLog(成功/失败) ──────────▶│ login_log
|
||||
│ │ MarkOnline ─────────▶│ ZAdd online:jti:set
|
||||
│ │ │ SET online:info:{jti}
|
||||
│◀── {access_token, refresh_token, expires_in, token_type} │
|
||||
```
|
||||
|
||||
## 登录八步(POST /api/login)
|
||||
|
||||
入参:`{username, password, tenantCode?, code?, key?}`(每一步失败均写登录日志 log_type=1 status=1):
|
||||
|
||||
```text
|
||||
1) 提供 key 时强制验证码校验(captcha.Verify,一次性)
|
||||
2) findUser:username 全局查找;带 tenantCode 先按 code 定位租户再过滤;
|
||||
多租户同名 → 优先平台租户(tenant_id=0),仍歧义 → 提示携带租户编码
|
||||
3) 账号状态 status==1 校验(1002 账号禁用)
|
||||
4) 租户状态与过期校验(mustLoadTenant,60s 缓存;1006 禁用 / 1005 过期)
|
||||
5) 密码:crypto.VerifyPassword(bcryptHash, password, salt)
|
||||
—— bcrypt(password+salt) 双重加盐;IsBcrypt 识别旧 md5 数据(登录拒绝,
|
||||
需重置密码升级);salt 为空兼容直接比对
|
||||
6) 签发双令牌(claims 含 user/tenant/username/realname)
|
||||
7) 写登录日志成功(log_type=1 status=0,含 ip/location/os/browser/consume_time;
|
||||
code 参数脱敏为 ***)
|
||||
8) MarkOnline 登记在线会话(zset + info 键)
|
||||
```
|
||||
|
||||
## 失败分支与错误码
|
||||
|
||||
| 失败点 | 错误码 | 提示 |
|
||||
|--------|--------|------|
|
||||
| 验证码错误/过期 | 1004 | 验证码错误 |
|
||||
| 用户不存在 | 1001 | 用户不存在 |
|
||||
| 账号禁用 | 1002 | 账号已禁用 |
|
||||
| 密码错误 | 1003 | 密码错误 |
|
||||
| 租户禁用 | 1006 | 租户已禁用 |
|
||||
| 租户过期 | 1005 | 租户已过期 |
|
||||
|
||||
全部失败路径都写登录日志(status=1 + error 原因),供安全审计。
|
||||
|
||||
## 租户内登录
|
||||
|
||||
```text
|
||||
场景:多个租户存在同名用户名
|
||||
处理:登录时携带 tenantCode(租户编码)
|
||||
findUser:带 tenantCode → 先按 code 定位 tenant_id → 再按 username 过滤
|
||||
不带:全局查找;多租户同名时优先平台租户(tenant_id=0);仍歧义 → 提示携带租户编码
|
||||
```
|
||||
|
||||
## 令牌生命周期状态图
|
||||
|
||||
```text
|
||||
Sign(access 2h) Refresh 轮换 / Logout / 强退
|
||||
────────▶ 有效 ────────────────────────────────────────▶ 黑名单
|
||||
▲ │ JWT中间件每请求: Parse + 黑名单Exists │
|
||||
│ ▼ ▼
|
||||
│ 到期(exp) ──前端──▶ POST /api/oauth2/token 自然过期(TTL
|
||||
│ (refresh_token 模式) = 剩余有效期)自动清理
|
||||
└── 旧 refresh 已拉黑则 1008;否则旧 refresh 入黑名单 + 签发新对
|
||||
```
|
||||
|
||||
## 前端对接要点
|
||||
|
||||
```text
|
||||
1. 登录成功:access/refresh 存本地;后续请求头 Authorization: Bearer <access>
|
||||
2. 响应拦截器:code=401 → 尝试 refresh(防并发刷新:单飞锁)→ 失败跳登录页
|
||||
3. refresh 也 1008/401 → 清令牌跳登录
|
||||
4. 登出:调 GET /api/logout 后清本地
|
||||
```
|
||||
|
||||
## 实战调试
|
||||
|
||||
```sh
|
||||
# 1. 拿验证码
|
||||
curl http://127.0.0.1:8000/api/captcha
|
||||
|
||||
# 2. 登录(bypassCode 免验证码)
|
||||
curl -X POST http://127.0.0.1:8000/api/login -H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"123456","code":"618618","key":"<key>"}'
|
||||
|
||||
# 3. 带令牌调业务
|
||||
curl http://127.0.0.1:8000/api/index/getUser -H "Authorization: Bearer <access_token>"
|
||||
|
||||
# 4. 刷新
|
||||
curl -X POST http://127.0.0.1:8000/api/oauth2/token -H "Content-Type: application/json" \
|
||||
-d '{"grant_type":"refresh_token","refresh_token":"<refresh_token>"}'
|
||||
|
||||
# 5. 登出(令牌拉黑)
|
||||
curl http://127.0.0.1:8000/api/logout -H "Authorization: Bearer <access_token>"
|
||||
```
|
||||
|
||||
排障:登录成功但接口 401 → 检查是否把 refresh_token 当 access 用;登出后旧令牌仍可用 → 该实例是否内存降级(黑名单不跨实例)。
|
||||
@@ -0,0 +1,69 @@
|
||||
# 5.3 验证码机制
|
||||
|
||||
:::tip 概述
|
||||
图形验证码由 utility/captcha 生成:6 位字符(排除易混淆字符)、PNG 渲染(TTF 字体 + 干扰线/噪点)、Redis 缓存 300 秒、一次性校验。字体缺失自动降级 SVG 渲染。
|
||||
:::
|
||||
|
||||
## 生成流程(GET /api/captcha)
|
||||
|
||||
```text
|
||||
1) 生成随机码:captcha.length 位(默认 6,范围 4~8),
|
||||
字符集 captcha.chars(默认排除 0O、1lI 等易混淆字符)
|
||||
2) 生成 uuid 作为缓存键
|
||||
3) 缓存:easygoadmin:captcha:{uuid} = 验证码值,TTL captcha.expire(300s)
|
||||
4) 渲染 PNG:TTF 字体(resource/static/fonts/Vera.ttf)+ 干扰线(默认 8 条)
|
||||
+ 噪点(默认 500 个);字体缺失降级 SVG
|
||||
5) 返回 {"key": uuid, "captcha": "data:image/png;base64,..."}(前端直接展示)
|
||||
```
|
||||
|
||||
## 校验流程(captcha.Verify)
|
||||
|
||||
```text
|
||||
Verify(ctx, key, code):
|
||||
1) GET easygoadmin:captcha:{uuid}(命中即删除——一次性校验)
|
||||
2) 未命中 → 校验失败(过期或已用过)
|
||||
3) 命中 → 与提交值比对(大小写不敏感处理)
|
||||
4) 配置 captcha.bypassCode 且提交值相等 → 免校验直接通过
|
||||
(仅开发调试使用,生产环境必须留空)
|
||||
```
|
||||
|
||||
## 配置项(manifest/config/config.yaml)
|
||||
|
||||
```yaml
|
||||
captcha:
|
||||
expire: 300 # 有效期(秒)
|
||||
bypassCode: "618618" # 万能验证码:携带该值免校验,仅开发调试使用,生产留空
|
||||
length: 6 # 字符数,取值范围 4 到 8
|
||||
chars: "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # 字符集,已排除易混淆字符
|
||||
fontPath: "resource/static/fonts/Vera.ttf" # TTF 字体路径,缺失降级 SVG
|
||||
imageWidth: 160 # 图片宽度(字符放不下时自动加宽防裁剪)
|
||||
imageHeight: 50 # 图片高度
|
||||
fontSize: 40 # 字体大小
|
||||
noiseLines: 8 # 干扰线数量(越多越难识别)
|
||||
noiseDots: 500 # 噪点数量
|
||||
```
|
||||
|
||||
## 使用场景
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| 登录 | 提供 key 时**强制**校验(auth Login 第 1 步) |
|
||||
| 令牌兑换 | 认证入口,与登录同享 authLimit 严阈值 |
|
||||
| 开发调试 | code 填 bypassCode(默认 618618)免校验 |
|
||||
| 生产 | bypassCode 必须留空(发布检查清单项) |
|
||||
|
||||
## 安全要点
|
||||
|
||||
+ **一次性**:命中即删,防重放(同一验证码不能二次使用)
|
||||
+ **短 TTL**:300 秒过期,防拖库后长期有效
|
||||
+ **独立限流**:/api/captcha 属认证入口,受 authLimit=10/分钟/IP 约束(防刷图)
|
||||
+ **缓存层**:经 utility/cache(Redis 优先、内存降级)——内存降级时验证码单实例内有效
|
||||
|
||||
## 常见问题
|
||||
|
||||
| 症状 | 原因与处理 |
|
||||
|------|-----------|
|
||||
| 验证码图片不显示 | 后端未启动 / /api/captcha 被限流;字体缺失不影响(降级 SVG) |
|
||||
| 填对了也报验证码错误 | 已用过(一次性)或超 300s;重新获取 |
|
||||
| 前端一直验证码错误但后端日志正常 | 检查 key 是否与图片配套(每次生成 key 不同) |
|
||||
| 生产环境想跳过验证码 | 禁止配置 bypassCode;调试可在测试环境进行 |
|
||||
@@ -0,0 +1,78 @@
|
||||
# 5.12 系统配置管理
|
||||
|
||||
:::tip 概述
|
||||
系统配置采用两级结构:config(配置分组)+ config_item(配置项)。面向"网站名称、Logo、开关类参数"等运行时可改的业务配置,与 manifest/config.yaml(工程配置)职责分离。
|
||||
:::
|
||||
|
||||
## 两级结构
|
||||
|
||||
```text
|
||||
goframe_config 配置分组:name / code(uk_code 全局唯一)/ sort
|
||||
goframe_config_item 配置项:config_id(所属分组)/ value / options / type / status / sort
|
||||
|
||||
示例:
|
||||
config: { name: "网站配置", code: "web" }
|
||||
config_item: { key: "siteName", value: "EasyGoAdmin", type: "input" }
|
||||
{ key: "siteLogo", value: "/upload/...", type: "image" }
|
||||
```
|
||||
|
||||
## 职责边界(与 config.yaml 的区分)
|
||||
|
||||
| | config 模块(数据库) | config.yaml(文件) |
|
||||
|--|---------------------|-------------------|
|
||||
| 面向 | 业务运营(站名/Logo/开关) | 工程运行(数据库/Redis/JWT/限流) |
|
||||
| 修改方式 | 管理页面在线改 | 改文件/环境变量 + 重启 |
|
||||
| 消费方 | 前端站点、业务接口 | 后端启动与中间件 |
|
||||
| 例 | 网站名称、备案号 | database.default.link、jwt.secret |
|
||||
|
||||
**判据**:改了要不要重启?要重启的是 yaml;不重启即时生效的是 config 模块。
|
||||
|
||||
## 接口清单
|
||||
|
||||
```text
|
||||
config(/api/config) 七件套 sys:config:*
|
||||
GET /api/config/web/index sys:configWeb:data 获取网站配置
|
||||
POST /api/config/web/save sys:configWeb:save 保存网站配置
|
||||
config_item(/api/config/item) 七件套 sys:configitem:*
|
||||
```
|
||||
|
||||
注意权限码特例:网站配置是 `sys:configWeb:data|save`(非 `sys:config:web*`)。
|
||||
|
||||
## 网站配置流(前端 setting 页面)
|
||||
|
||||
```text
|
||||
前端加载:GET /api/config/web/index
|
||||
→ 后端按分组 code="web" 聚合 config_item
|
||||
→ 返回 {siteName, siteLogo, ...}(type 决定前端渲染控件)
|
||||
|
||||
保存:POST /api/config/web/save
|
||||
→ 批量更新 config_item.value
|
||||
→ 其他实例下次读取即生效(在线配置,无缓存层)
|
||||
```
|
||||
|
||||
## 配置项类型(type)
|
||||
|
||||
| type | 前端控件 | 示例 |
|
||||
|------|---------|------|
|
||||
| input | 文本框 | 站点名称 |
|
||||
| image | 图片选择 | 站点 Logo |
|
||||
| select | 下拉(options 提供选项) | 开关类配置 |
|
||||
| textarea | 多行文本 | 备案信息/统计代码 |
|
||||
|
||||
options 列存 JSON 选项数组,配合 select 类型使用。
|
||||
|
||||
## 开发新配置项
|
||||
|
||||
```text
|
||||
1. 字典/配置管理页面:确认 config 分组存在(如 web),不存在先建分组
|
||||
2. config_item 新增行:key / value / type / options / sort
|
||||
3. 前端读取处消费(GET /api/config/web/index 的返回字段)
|
||||
4. 后端需要消费时(少用):查询 config_item 按 key 取值
|
||||
```
|
||||
|
||||
## 运维注意
|
||||
|
||||
+ config 模块是**平台级**(无租户隔离)——租户差异化配置不在设计内
|
||||
+ code 全局唯一(uk_code),分组语义清晰避免冲突
|
||||
+ 在线保存即时生效,无需重启;无缓存层(读频率低,直接查库)
|
||||
+ 与 `app.demo` 等工程开关分离——工程开关不放 config 模块(重启才能改语义的不适宜在线化)
|
||||
@@ -0,0 +1,64 @@
|
||||
# 5.5 跨域资源共享
|
||||
|
||||
:::tip 概述
|
||||
CORS 由中间件链第一层处理(middleware/cors.go)。开发环境前端经 Vite 代理天然同源;跨域直连场景由 `cors.allowOrigin` 配置控制,支持全放行与指定来源两种模式。
|
||||
:::
|
||||
|
||||
## 配置
|
||||
|
||||
```yaml
|
||||
cors:
|
||||
allowOrigin: "*" # 允许的来源,为空或 * 时使用框架内置策略
|
||||
```
|
||||
|
||||
## 两种模式
|
||||
|
||||
```text
|
||||
模式一:allowOrigin 为空或 "*"
|
||||
→ r.Response.CORSDefault()(框架内置全放行策略)
|
||||
|
||||
模式二:allowOrigin 为具体来源
|
||||
→ 显式回显:
|
||||
Access-Control-Allow-Origin: <配置值>
|
||||
Access-Control-Allow-Credentials: true
|
||||
Access-Control-Allow-Headers: Origin,Authorization,Content-Type,Accept,Token,X-Requested-With
|
||||
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
|
||||
→ OPTIONS 预检直接 204 返回
|
||||
```
|
||||
|
||||
## 允许的请求头
|
||||
|
||||
```text
|
||||
Origin
|
||||
Authorization ← JWT Bearer 令牌
|
||||
Content-Type ← JSON / multipart
|
||||
Accept
|
||||
Token
|
||||
X-Requested-With
|
||||
```
|
||||
|
||||
## 与认证的配合
|
||||
|
||||
```text
|
||||
1. CORS 中间件在链首——预检 OPTIONS 在进入 JWT 前就完成响应
|
||||
2. 带令牌的跨域请求:Authorization 头已在 Allow-Headers 白名单
|
||||
3. 前端开发环境走 Vite proxy(同源),生产建议 Nginx 同域反代——
|
||||
同域部署时 CORS 实际不生效,最安全
|
||||
```
|
||||
|
||||
## 生产建议
|
||||
|
||||
::: warning 安全取舍
|
||||
+ `allowOrigin: "*"` + 凭证(Cookie 类)组合是危险配置——本系统用 Bearer 头(非 Cookie),风险较低,但**公网服务仍建议配置具体来源域名**。
|
||||
+ 前端与后端同域部署(Nginx 反代 /api)是推荐方案,从根上消除 CORS 需求。
|
||||
+ 修改来源配置后需重启后端生效(中间件启动时读取)。
|
||||
:::
|
||||
|
||||
## 排障
|
||||
|
||||
| 症状 | 检查 |
|
||||
|------|------|
|
||||
| 浏览器报 CORS blocked | 前端是否直连后端且 allowOrigin 不含该来源;推荐改同域反代 |
|
||||
| 预检通过但正式请求 401 | 非 CORS 问题——是 JWT(令牌缺失/过期/黑名单) |
|
||||
| 带 cookie 报凭证错误 | 本系统不用 Cookie 会话,检查前端是否误带 credentials |
|
||||
| 生产偶发跨域失败 | 多域名场景确认 allowOrigin 是否支持多值(逗号分隔需验证);或收敛到同域 |
|
||||
@@ -0,0 +1,83 @@
|
||||
# 5.9 数据权限
|
||||
|
||||
:::tip 概述
|
||||
角色数据权限(role.data_scope)是叠加在租户隔离之上的第二层行级过滤,四档可配置。模块通过 `DataScope: ApplyDataScope("dept_id")` 一行声明接线,仅作用于 Page/List/导出。
|
||||
:::
|
||||
|
||||
## 四档数据范围
|
||||
|
||||
| 档位 | 含义 | 过滤条件(作用于挂接模块的列表行) |
|
||||
|------|------|----------------------------------|
|
||||
| 1 | 全部 | 不过滤(租户隔离之内全量) |
|
||||
| 2 | 本部门及以下 | dept_id IN (自身部门 + 全部子孙部门 ID) |
|
||||
| 3 | 本部门 | dept_id = 自身部门 ID |
|
||||
| 4 | 仅本人 | id = 自身用户 ID |
|
||||
|
||||
档位文案走字典 `role_data_scope`(定稿种子已写入 dict/dict_item),角色表单"数据范围"下拉由字典驱动。
|
||||
|
||||
## 模块接线(一行声明)
|
||||
|
||||
```go
|
||||
// internal/logic/user.go(真实接线方式)
|
||||
userSpec = ModuleSpec[...]{ ...,
|
||||
DataScope: ApplyDataScope("dept_id"),
|
||||
}
|
||||
```
|
||||
|
||||
`ApplyDataScope(deptColumn)` 返回 DataScopeFunc 闭包(logic/data_scope.go,共享工厂),挂进 applyFilters 末环:
|
||||
|
||||
```text
|
||||
Like → Eq → Keyword → Filter → DataScope
|
||||
租户/软删隔离由 dao.Ctx 先于本链注入
|
||||
```
|
||||
|
||||
接线后 **Page/List/导出自动生效**。目前仅 user 模块接线(全库仅 goframe_user 有 dept_id 列);其他模块需要时声明同款闭包即可,无部门归属列的表可按需扩展"按创建人过滤"变体。
|
||||
|
||||
## 与租户隔离的叠加关系
|
||||
|
||||
```text
|
||||
第一层(硬规则,不可配置):租户隔离
|
||||
dao.Ctx 注入 tenant_id IN(0,自身),先于一切业务过滤,任何模块不可绕过
|
||||
|
||||
第二层(可配置):角色数据范围
|
||||
ModuleSpec.DataScope 挂点,applyFilters 末环叠加
|
||||
两层独立:先租户收窄,再数据范围收窄
|
||||
```
|
||||
|
||||
## 安全语义(与直觉不同的点)
|
||||
|
||||
::: warning 五条语义
|
||||
1. **多角色取最宽松**(并集直觉)——合并发生在 LoadIdentity 缓存层(数值 min,1 最宽),热路径零额外查询
|
||||
2. **超管/平台完全绕过**(IsPlatform 或 Roles 含 ROLE_SUPER_ADMIN,dataScopeBypass 双判断)
|
||||
3. **未分配部门(dept_id=0)用户挂档 2/3 时降级为"仅本人"**(防 dept_id=0 意外放大可见面)
|
||||
4. **仅作用于 Page/List/导出**(applyFilters 链);Detail/Update/Delete 不过滤,由 permission 节点与租户隔离兜底——管理端点授予权限时应评估该角色是否应有写权限
|
||||
5. 部门子树 deptSubtreeIds:拉全租户部门行(id,parent_id)后 BFS(树小直查无缓存;成热点可加短时缓存)
|
||||
:::
|
||||
|
||||
## 配置链路(修改如何生效)
|
||||
|
||||
```text
|
||||
role 表 data_scope 列(默认 1)
|
||||
→ 角色表单"数据范围"下拉(字典 role_data_scope 驱动,值 1/2/3/4)
|
||||
→ roleToData 归一(缺省回 1,gvalid in:1,2,3,4 校验)
|
||||
→ 角色 Update 后 AfterUpdate 清该角色全部用户身份缓存
|
||||
→ 生效(新请求的 LoadIdentity 读到新档位)
|
||||
```
|
||||
|
||||
Page/Detail 返回 `dataScope + dataScopeText`(字典驱动),前端可展示当前角色数据范围。
|
||||
|
||||
## 上下文读取
|
||||
|
||||
```text
|
||||
GetDataScope(ctx) 档位(1-全部;未注入视为全部——旧缓存缺字段回退安全值)
|
||||
GetDeptID(ctx) 当前用户部门 ID(0=未分配)
|
||||
```
|
||||
|
||||
两者由 JWT 中间件从身份缓存注入,业务代码一般不直接读(基座 DataScope 闭包内部消费)。
|
||||
|
||||
## 已知边界
|
||||
|
||||
+ 数据权限当前仅 user 模块接线;其他模块行级可见性由租户隔离单层兜底
|
||||
+ Detail/Update/Delete 不做行级过滤——越权改删他人记录的防线是接口 permission + 租户隔离
|
||||
+ 身份缓存 300s 窗口内直接改库的 dept_id/data_scope 不即时生效(API 路径均主动清缓存)
|
||||
+ 第 5 档自定义范围(如按部门多选)为预留能力,需部门关联表 + 角色表单扩展
|
||||
@@ -0,0 +1,68 @@
|
||||
# 5.7 演示模式
|
||||
|
||||
:::tip 概述
|
||||
演示模式(app.demo)为对外演示站设计:读操作照常、写操作一律拦截。一套配置让演示站可浏览、可登录、但不可篡改数据。
|
||||
:::
|
||||
|
||||
## 工作原理(middleware/demo.go)
|
||||
|
||||
```text
|
||||
app.demo = true 时(中间件链第 4 层):
|
||||
|
||||
放行:
|
||||
* GET / HEAD / OPTIONS(读操作,演示站可浏览)
|
||||
* 公开白名单路径(/api/login、/api/captcha 等,保证演示站可登录)
|
||||
|
||||
拦截:
|
||||
* 其余写操作(POST / PUT / PATCH / DELETE)
|
||||
→ code=1015 "演示环境,禁止操作!"
|
||||
|
||||
app.demo = false(生产默认):中间件直接放行,零开销
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
```yaml
|
||||
app:
|
||||
demo: false # 演示模式:为 true 时写操作一律拒绝,读操作与登录类公开接口放行
|
||||
```
|
||||
|
||||
## 拦截行为细节
|
||||
|
||||
| 请求 | demo=true 行为 |
|
||||
|------|---------------|
|
||||
| GET /api/user/page | ✅ 放行(浏览) |
|
||||
| POST /api/login | ✅ 放行(公开白名单,演示站可登录) |
|
||||
| GET /api/captcha | ✅ 放行(公开白名单) |
|
||||
| POST /api/user/add | ❌ 1015 演示环境,禁止操作! |
|
||||
| PUT /api/user/update | ❌ 1015 |
|
||||
| DELETE /api/user/delete/1 | ❌ 1015 |
|
||||
| POST /api/upload/uploadFile | ❌ 1015 |
|
||||
|
||||
## 设计取舍
|
||||
|
||||
```text
|
||||
为什么按 HTTP 方法而非路径白名单拦截:
|
||||
* 写操作方法(POST/PUT/PATCH/DELETE)与语义严格对应,无遗漏风险
|
||||
* 免维护"哪些写接口可放行"的清单
|
||||
* 登录/验证码是 GET 或公开白名单路径,天然不受影响
|
||||
|
||||
代价:demo 模式下无法演示"新增/编辑"交互——这是有意的取舍,
|
||||
数据安全优先于演示完整性。
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
::: warning 环境隔离
|
||||
+ 演示站:`app.demo: true` + 独立数据库 + 独立 jwt.secret
|
||||
+ 本地开发:`app.demo: false`(否则改不动数据还以为是权限问题)
|
||||
+ 生产:`app.demo: false`(发布检查清单项)
|
||||
:::
|
||||
|
||||
## 排障
|
||||
|
||||
| 症状 | 判断 |
|
||||
|------|------|
|
||||
| 写操作全部报"演示环境,禁止操作!" | `app.demo: true`,改回 false 并重启 |
|
||||
| 只有部分写操作被拦 | 不应出现——demo 按方法拦截无差别生效;若出现检查是否 429 限流或 403 权限的干扰 |
|
||||
| 演示站登录后点什么都报 1015 | 正常现象,公开白名单仅覆盖登录链路 |
|
||||
@@ -0,0 +1,100 @@
|
||||
# 5.11 字典配置与使用
|
||||
|
||||
:::tip 概述
|
||||
本节是 {field}Text 字典驱动的使用手册:如何声明、填充链路如何工作、文案如何修改生效、常见坑有哪些。后端声明一处,全链路(列表/详情/导出)自动带显示名。
|
||||
:::
|
||||
|
||||
## 使用三步
|
||||
|
||||
```text
|
||||
1. 建表时列注释写枚举: status int DEFAULT 1 COMMENT '状态:1-正常 2-禁用'
|
||||
(生成器自动创建字典 user_status + 字典项;或字典管理页手工建)
|
||||
|
||||
2. logic 模块头部声明映射:
|
||||
var userSerializeMaps = map[string]string{
|
||||
"status": "user_status",
|
||||
}
|
||||
挂进 spec:SerializeMaps: userSerializeMaps
|
||||
|
||||
3. api/v1 行结构体加 Text 字段:
|
||||
Status int `json:"status"`
|
||||
StatusText string `json:"statusText"` // 状态显示名(dict_item 解析)
|
||||
```
|
||||
|
||||
读取时基座自动填充,前端直接展示 `statusText`。
|
||||
|
||||
## 填充链路(机制详解)
|
||||
|
||||
```text
|
||||
spec.SerializeMaps 声明 {字段: 字典编码}
|
||||
│
|
||||
▼
|
||||
基座 FillTextFields(结构体存在 {Field}Text string 字段即自动填充,
|
||||
反射计划按类型缓存)
|
||||
│
|
||||
▼
|
||||
DictGetText(ctx, code, value)
|
||||
│ 查 dict→dict_item 的 name
|
||||
▼
|
||||
进程内缓存 dictTetxCache({code}:{value} → 显示名,TTL 300s)
|
||||
│ 未命中回源 Redis(dict:item:{code},3600s)→ 数据库
|
||||
▼
|
||||
返回显示名;查无此项返回原值(不报错)
|
||||
```
|
||||
|
||||
map 场景(非结构体,如导出行)用 `SerializeMapOnRead(ctx, row, maps)` 原地补 `row[field+"Text"]`。
|
||||
|
||||
## 修改文案如何生效
|
||||
|
||||
| 场景 | 生效时间 |
|
||||
|------|---------|
|
||||
| 字典管理页改 dict_item.name | 进程内缓存最迟 5 分钟(300s)自然过期 |
|
||||
| 需要立即全实例生效 | 调 `GET /api/dict/refreshCache`(刷 Redis 层;进程内层仍等过期) |
|
||||
| 新增字典项 | 同上(旧值缓存不受影响) |
|
||||
|
||||
::: warning 三层缓存认知
|
||||
Redis 层(3600s,refreshCache 主动刷)→ 进程内层(300s,只等过期)→ 无缓存。改文案"看起来没生效"通常是进程内层未过期,等 5 分钟或重启。
|
||||
:::
|
||||
|
||||
## 导出中的字典文本
|
||||
|
||||
导出不走结构体填充,显式调用(真实模式,摘自 example 导出):
|
||||
|
||||
```go
|
||||
data = append(data, []any{
|
||||
r["name"].String(),
|
||||
DictGetText(ctx, "example_type", r["type"]),
|
||||
DictGetText(ctx, "example_status", r["status"]),
|
||||
})
|
||||
```
|
||||
|
||||
枚举列输出字典文本,Excel 直接可读(导出=所见)。
|
||||
|
||||
## 前端消费
|
||||
|
||||
```text
|
||||
1. 行内 {field}Text:直接展示后端返回的 statusText(首选——文案唯一真相在字典)
|
||||
2. 筛选下拉选项:调 /api/dict/item/getDictItemList/:code 拿字典项驱动 Select
|
||||
(example 模块 querySchemas 的 options 是静态示例;真实模块建议字典驱动)
|
||||
3. 表单单选/多选:同上,按字典编码取选项
|
||||
```
|
||||
|
||||
## 命名与例外
|
||||
|
||||
```text
|
||||
编码规范:{模块snake}_{字段snake}(user_status / job_execute_policy / example_status)
|
||||
公共字典:role_data_scope(数据范围)等不带模块前缀
|
||||
|
||||
例外:operation_log.type 存注解常量值(0-40/99),用静态表 LogTypeText
|
||||
(consts.LogTypeNames),不走字典——数值与 gmeta logType 绑定,改动会破坏存量日志
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
| 症状 | 原因与处理 |
|
||||
|------|-----------|
|
||||
| {field}Text 全是数字 | spec 未声明 SerializeMaps / 结构体无 {Field}Text 字段 / 字典编码写错 |
|
||||
| 部分值有文本部分没有 | dict_item 缺该 value 的项 |
|
||||
| 改了文案没变化 | 进程内 300s 缓存未过期;调 refreshCache 只刷 Redis 层 |
|
||||
| 生成器没建字典 | 列注释格式不对(须 `字段:1-x 2-y` 形式)或非 MySQL 库内省不到注释 |
|
||||
| 查询返回原值 | DictGetText 设计为"查无返回原值"——排查 dict/dict_item 数据 |
|
||||
@@ -0,0 +1,85 @@
|
||||
# 5.10 字典模块
|
||||
|
||||
:::tip 概述
|
||||
字典是枚举数据的唯一真相:dict(字典)+ dict_item(字典项)两级结构,驱动全系统的 {field}Text 显示名。文案修改在字典管理页完成,无需改代码。
|
||||
:::
|
||||
|
||||
## 数据结构
|
||||
|
||||
```text
|
||||
goframe_dict 字典表:name / code(uk_code 全局唯一)/ sort
|
||||
goframe_dict_item 字典项表:dict_id / name / value / sort
|
||||
|
||||
示例(role_data_scope 字典):
|
||||
dict: { code: "role_data_scope", name: "数据范围" }
|
||||
dict_item: { name: "全部", value: 1 }
|
||||
{ name: "本部门及以下", value: 2 }
|
||||
{ name: "本部门", value: 3 }
|
||||
{ name: "仅本人", value: 4 }
|
||||
```
|
||||
|
||||
## 字典编码规范
|
||||
|
||||
```text
|
||||
编码 = {模块snake}_{字段snake}
|
||||
|
||||
示例:user_status(用户-状态)、job_execute_policy(任务-执行策略)、
|
||||
example_status(案例-状态)、role_data_scope(数据范围,公共字典)
|
||||
|
||||
创建途径:
|
||||
1) 代码生成器 createDict:按列注释枚举自动创建(幂等,按 code 查重)
|
||||
2) 字典管理页手工创建
|
||||
```
|
||||
|
||||
## 接口清单
|
||||
|
||||
```text
|
||||
/sys:dict:* 七件套(page/detail/add/update/delete/batchDelete;无 list,列表经 page)
|
||||
GET /api/dict/refreshCache sys:dict:cache 刷新字典缓存
|
||||
GET /api/dictionary/list 登录即可 字典列表别名(前端 dictionary.ts)
|
||||
GET /api/dictionary/info 登录即可 字典详情别名
|
||||
/sys:dictitem:* 七件套
|
||||
GET /api/dict/item/getDictItemList/:code 登录即可 按编码查字典项(缓存)
|
||||
```
|
||||
|
||||
注意:dict 无 `/dict/list`,列表经 page 或 `/api/dictionary/list` 别名。
|
||||
|
||||
## 缓存策略(两层)
|
||||
|
||||
| 层 | 键/容器 | TTL | 失效 |
|
||||
|----|---------|-----|------|
|
||||
| Redis | `easygoadmin:dict:item:{code}` | 3600s | RefreshCache 主动重建;dict_item 变更不主动清(最长 1 小时延迟) |
|
||||
| 进程内 | dictTextCache({code}:{value} → 显示名) | 300s | 各实例自然过期 |
|
||||
|
||||
关键场景(改完文案要立即生效):调 `GET /api/dict/refreshCache` 刷新 Redis 层;进程内层最迟 5 分钟自然过期。
|
||||
|
||||
## 在业务中的消费({field}Text)
|
||||
|
||||
```go
|
||||
// logic 模块头部声明(真实模式)
|
||||
var xxxSerializeMaps = map[string]string{
|
||||
"status": "user_status",
|
||||
}
|
||||
|
||||
// 基座读取时自动填充结构体的 {Field}Text 字段
|
||||
// 取值链:DictGetText(ctx, "user_status", 1) → 查 dict→dict_item 的 name → 进程内缓存
|
||||
```
|
||||
|
||||
详见 [5.11 字典配置与使用](./dict-guide)。
|
||||
|
||||
## 与生成器的联动
|
||||
|
||||
```text
|
||||
建表时列注释写枚举:"状态:1-正常 2-禁用"(- — = = 均可作分隔)
|
||||
→ 生成器 createDict 解析
|
||||
→ 自动创建 dict(name="{模块中文名}-{字段中文名}",note 标注自动创建)
|
||||
→ 逐 Choice 建 dict_item(name=label, value, sort+5)
|
||||
→ 结果逐项返回(已存在/创建成功/失败原因),幂等
|
||||
```
|
||||
|
||||
## 运维要点
|
||||
|
||||
+ 字典文案修改后,展示最迟 5 分钟(进程内层)生效;紧急场景调 refreshCache
|
||||
+ 定稿种子已含 role_data_scope 等公共字典(随 SQL 脚本导入)
|
||||
+ 字典 code 全局唯一(uk_code),创建前查重
|
||||
+ 非枚举字段不要建字典——{field}Text 只用于枚举列
|
||||
@@ -0,0 +1,104 @@
|
||||
# 5.16 异常与响应处理
|
||||
|
||||
:::tip 概述
|
||||
全部响应经 utility/response 统一信封写出;panic 由 Response 中间件 recover 兜底;404/500 状态页输出 JSON 而非 HTML。前端按 code 分流处理(401 跳登录、1 弹 msg)。
|
||||
:::
|
||||
|
||||
## 统一响应信封(utility/response)
|
||||
|
||||
```text
|
||||
成功:{"code":0, "ok":true, "msg":"操作成功", "data":{...}}
|
||||
失败:{"code":1, "ok":false, "msg":"...", "data":{}}
|
||||
未认证:HTTP 401 + {"code":401,...}(前端据此跳登录页)
|
||||
```
|
||||
|
||||
写出响应时把 code/body 记入 ctx(respCodeKey/respBodyKey),供 OperationLog 中间件判断成功/失败与记录响应内容。
|
||||
|
||||
## Fail 映射规则(重要)
|
||||
|
||||
```text
|
||||
response.Fail(err):
|
||||
gerror 码 == 401 → 原样保留 code=401(配合 HTTP 401)
|
||||
其余 → 一律降级 code=1
|
||||
|
||||
含义:consts 里的细分错误码(1001 用户不存在、1003 密码错误……)
|
||||
主要用于日志与语义区分;前端只看 0/1/401 三个关键值:
|
||||
0=成功;1=失败(弹 msg);401=未认证(跳登录)
|
||||
```
|
||||
|
||||
## 业务错误码(consts/biz_err.go)
|
||||
|
||||
| 码 | 常量 | 含义 |
|
||||
|----|------|------|
|
||||
| 1 | CodeFailed | 通用操作失败 |
|
||||
| 400 | CodeParamError | 参数校验失败 |
|
||||
| 401 | CodeUnauthorized | 登录已过期,请重新登录 |
|
||||
| 403 | CodeForbidden | 没有该操作权限 |
|
||||
| 429 | CodeTooManyRequests | 请求过于频繁 |
|
||||
| 500 | CodeServerError | 服务器内部错误 |
|
||||
| 1001 | — | 用户不存在 |
|
||||
| 1002 | — | 账号禁用 |
|
||||
| 1003 | — | 密码错误 |
|
||||
| 1004 | — | 验证码错误 |
|
||||
| 1005 | — | 租户过期 |
|
||||
| 1006 | — | 租户禁用 |
|
||||
| 1007 | — | 租户用户数上限 |
|
||||
| 1008 | — | Token 无效 |
|
||||
| 1009 | — | 记录已存在 |
|
||||
| 1010 | — | 记录不存在 |
|
||||
| 1011 | — | 存在下级或关联 |
|
||||
| 1012 | — | 原密码错误 |
|
||||
| 1013 | — | 租户不存在 |
|
||||
| 1014 | — | 记录已被引用 |
|
||||
| 1015 | — | 演示环境禁止操作 |
|
||||
|
||||
(完整对照见 [8.2 错误码对照表](../api-spec/error-code)。)
|
||||
|
||||
## panic 兜底(Response 中间件)
|
||||
|
||||
```text
|
||||
defer recover():
|
||||
1) 记录堆栈日志(errorStack 控制是否随响应返回)
|
||||
2) 尚未写出响应 → 输出 500 统一信封(CodeServerError)
|
||||
3) 已写出响应 → 只记录日志,避免双写
|
||||
|
||||
app.errorMsg 控制是否返回详细错误信息(生产建议 false)
|
||||
server.errorStack 控制响应是否携带错误堆栈(生产建议 false)
|
||||
```
|
||||
|
||||
## 状态页(404/500)
|
||||
|
||||
```text
|
||||
router.go BindStatusHandler:
|
||||
404 → {"code":1,"msg":"接口不存在: GET /api/xxx"}
|
||||
500 → {"code":1,"msg":"服务器内部错误"}
|
||||
|
||||
非 200 一律输出统一 JSON 信封,不返回 HTML(前后端分离友好)
|
||||
```
|
||||
|
||||
## 参数校验失败(400)
|
||||
|
||||
```text
|
||||
r.Parse(req) 触发 gvalid 标签校验
|
||||
→ 失败:{"code":400,"msg":"请输入案例名称"}(文案取 v 标签 # 后缀)
|
||||
→ 业务方法不执行(短路)
|
||||
```
|
||||
|
||||
## 前端处理策略
|
||||
|
||||
```text
|
||||
响应拦截器:
|
||||
code === 0 → 返回 data
|
||||
code === 401 → 清令牌 → 尝试 refresh → 失败跳登录页
|
||||
code === 1 → ElMessage.error(msg)(ResultEnum.ERROR)
|
||||
其他码 → 按业务需要定制(一般走 1 的统一弹窗即可)
|
||||
```
|
||||
|
||||
## 排障
|
||||
|
||||
| 症状 | 判断 |
|
||||
|------|------|
|
||||
| 500 无堆栈 | app.errorMsg/server.errorStack=false(生产预期);看服务端日志 |
|
||||
| 错误全是 code=1 | 设计如此——细分码在 msg 与服务端日志中区分 |
|
||||
| 参数错误无具体文案 | v 标签未写 # 自定义文案,用了框架默认提示 |
|
||||
| 404 返回 HTML | 不应出现——确认经 BindStatusHandler 注册的入口访问(直连静态资源除外) |
|
||||
@@ -0,0 +1,87 @@
|
||||
# 5.17 接口限流
|
||||
|
||||
:::tip 概述
|
||||
接口防护采用固定窗口限流(middleware/ratelimit.go):按 IP + 路径计数,普通接口与认证入口双阈值。计数经 Redis 跨实例共享,存储异常时放行(限流不阻断业务)。
|
||||
:::
|
||||
|
||||
## 配置
|
||||
|
||||
```yaml
|
||||
rateLimit:
|
||||
enable: true # 是否启用限流
|
||||
limit: 120 # 普通接口每窗口每 IP 每路径最大请求数,0 关闭普通限流
|
||||
windowSeconds: 60 # 计数窗口(秒)
|
||||
authLimit: 10 # 登录/验证码/令牌兑换每窗口每 IP 最大请求数(更严格)
|
||||
```
|
||||
|
||||
## 规则明细
|
||||
|
||||
```text
|
||||
作用范围:
|
||||
* 仅限 /api 前缀路径;/api/health 显式豁免
|
||||
* /api.json、/swagger、静态资源不带 /api/ 前缀,自然不受限
|
||||
|
||||
双阈值:
|
||||
* 普通接口:limit(默认 120 次/窗口/IP/路径)
|
||||
* 认证入口(/api/login、/api/captcha、/api/oauth2/token):authLimit(默认 10 次/窗口/IP)
|
||||
|
||||
计数键:ratelimit:{clientIP}:{path}:{窗口起点}
|
||||
窗口起点 = Unix/window*window 对齐(固定窗口语义)
|
||||
TTL = window+5s(仅用于回收,不影响窗口归属)
|
||||
|
||||
计数实现:incrCounter——GET→SET 自增;不存在时 SET NX 抢占;
|
||||
并发下 NX 失败再读现值加一
|
||||
|
||||
计数存储:utility/cache(Redis 优先、内存降级)→ 多实例共享计数
|
||||
|
||||
超限响应:code=429 信封(CodeTooManyRequests)
|
||||
存储异常:放行(限流不阻断业务)
|
||||
```
|
||||
|
||||
## 真实 IP(防绕过)
|
||||
|
||||
```text
|
||||
util.go realClientIP:
|
||||
直连来源不在 api.proxyIps(逗号分隔,精确 IP 与 CIDR)内
|
||||
→ 直接用 GetRemoteIp()(不信任任何转发头)
|
||||
在列表内 → 才信任 X-Forwarded-For 首段 / X-Real-IP
|
||||
|
||||
目的:防止伪造 XFF 绕过限流或污染日志
|
||||
部署:Nginx 反代务必把反代网段加入 api.proxyIps,否则全站记反代 IP
|
||||
```
|
||||
|
||||
## 固定窗口的语义与边界
|
||||
|
||||
```text
|
||||
固定窗口特点:窗口边界处可能出现 2 倍瞬时放行(如 59s 放 120 + 61s 放 120)
|
||||
本项目取舍:实现简单、Redis 计数成本低;对管理后台场景足够
|
||||
(非支付级防刷场景,无需滑动窗口/令牌桶)
|
||||
|
||||
限流粒度:IP + 路径 —— 同 IP 调不同路径各自计数
|
||||
```
|
||||
|
||||
## 与整体防护的关系
|
||||
|
||||
```text
|
||||
接口限流(本节) IP 维度,防刷与暴力破解
|
||||
+ 认证专项 authLimit 独立严阈值(验证码/登录/令牌)
|
||||
+ 演示模式 demo=true 拦截全部写操作
|
||||
+ 权限校验 403 拒绝未授权
|
||||
+ jti 黑名单 已撤销令牌即时失效
|
||||
```
|
||||
|
||||
## 调参与排障
|
||||
|
||||
| 场景 | 处理 |
|
||||
|------|------|
|
||||
| 正常用户被误伤(429) | 压测/办公出口 IP 共享场景调大 limit;authLimit 不建议调大 |
|
||||
| 限流不生效 | enable=false?内存降级下单机计数仍应生效;确认路径带 /api 前缀 |
|
||||
| 多实例阈值翻倍 | Redis 不可用内存降级(各实例各自计数)——查 /api/cache/info |
|
||||
| 反代后全站共享阈值 | api.proxyIps 未含反代出口(全站记反代 IP,共同一个桶) |
|
||||
| 突发 429 后恢复慢 | 固定窗口未到窗口起点;键 TTL=window+5s 保证自动回收 |
|
||||
|
||||
::: warning 生产建议
|
||||
+ authLimit(认证入口 10 次/分钟/IP)是防暴力破解的关键阈值,不建议调大
|
||||
+ 限流计数依赖 Redis——内存降级时多实例部署下实际阈值 = 配置值 × 实例数
|
||||
+ 压测前临时调大 limit 或按压测 IP 加白(受信代理配置不等同限流白名单,需另行处理)
|
||||
:::
|
||||
@@ -0,0 +1,135 @@
|
||||
# 5.1 用户认证(JWT 双令牌)
|
||||
|
||||
:::tip 概述
|
||||
JWT(JSON Web Token)是系统核心认证机制。采用双令牌设计:access_token 用于接口认证(2 小时),refresh_token 用于令牌刷新(7 天)。无状态、可水平扩展,配合 jti 黑名单实现可控的会话撤销。
|
||||
:::
|
||||
|
||||
## 双令牌机制
|
||||
|
||||
```text
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ 用户登录 │──── username ────►│ │
|
||||
│ │──── password ────►│ auth │
|
||||
│ │──── code/key ────►│ .Login()│
|
||||
└──────────┘ └────┬─────┘
|
||||
│
|
||||
┌─────────────────┼─────────────────┐
|
||||
▼ ▼ ▼
|
||||
access_token refresh_token user info
|
||||
(2小时有效) (7天有效) (基础信息)
|
||||
```
|
||||
|
||||
### 两种令牌的区别
|
||||
|
||||
| 特性 | access_token | refresh_token |
|
||||
|------|-------------|---------------|
|
||||
| 用途 | 接口认证(每个 API 请求携带) | 刷新令牌(仅在 access_token 过期时使用) |
|
||||
| 有效期 | 2 小时(7200 秒,`jwt.accessTokenExpire`) | 7 天(604800 秒) |
|
||||
| claims.typ | `access` | `refresh` |
|
||||
| 传递方式 | `Authorization: Bearer <token>` | POST `/api/oauth2/token` 请求体 |
|
||||
| 校验方 | JWT 中间件 | logic/auth Refresh |
|
||||
|
||||
## Claims 结构(utility/jwt,真实实现)
|
||||
|
||||
```text
|
||||
算法 HS256;密钥 jwt.secret;签发者 jwt.issuer(默认 easygoadmin)
|
||||
|
||||
Claims(golang-jwt/v5 + RegisteredClaims):
|
||||
user_id / tenant_id / username / realname / typ(access|refresh)
|
||||
RegisteredClaims:jti(随机)、iss、iat、exp(exp 必填,Parse 强校验)
|
||||
```
|
||||
|
||||
关键 API:
|
||||
|
||||
```text
|
||||
Sign(ctx, tokenType, userID, tenantID, username, realname) → (token, claims, err)
|
||||
Parse(ctx, tokenStr) HS256 方法限定(防算法混淆)+ 签名/过期/签发者校验,
|
||||
失败统一 401 "Token无效或已失效"
|
||||
ExpireDuration(ctx, typ) 有效期(黑名单 TTL 计算用)
|
||||
```
|
||||
|
||||
## 令牌全生命周期
|
||||
|
||||
```text
|
||||
1. 登录
|
||||
POST /api/login {username, password, code, key}
|
||||
│ 签发 access_token + refresh_token
|
||||
▼
|
||||
前端存储(access 用于后续请求,refresh 用于续期)
|
||||
|
||||
2. 请求 API
|
||||
GET /api/user/page
|
||||
Authorization: Bearer <access_token>
|
||||
│ JWT 中间件:Parse → 黑名单 Exists → 注入身份
|
||||
▼
|
||||
controller → service → logic → 响应
|
||||
|
||||
3. access 到期(前端响应拦截器检测 401)
|
||||
POST /api/oauth2/token {grant_type: refresh_token, refresh_token: <refresh>}
|
||||
│ 校验 typ=refresh → 旧 refresh 已黑?→ 轮换(旧黑签新)
|
||||
▼
|
||||
返回新令牌对(旧 access 自然过期,旧 refresh 入黑名单防重放)
|
||||
|
||||
4. 登出 / 强退
|
||||
GET /api/logout 或 forceLogout
|
||||
│ 当前 access jti 入黑名单(TTL=剩余有效期)
|
||||
│ 强退额外拉黑 refresh jti(防被踢会话经 refresh 复活)
|
||||
▼
|
||||
MarkOffline:在线会话注销
|
||||
```
|
||||
|
||||
## 黑名单机制
|
||||
|
||||
```text
|
||||
键:easygoadmin:token:blacklist:{jti} = "1"
|
||||
TTL:令牌剩余有效期(过期即令牌本身已失效,无需主动清理)
|
||||
|
||||
写入时机:
|
||||
* 登出(Logout)
|
||||
* refresh 轮换(旧 refresh 拉黑)
|
||||
* RemoveToken 指定撤销(DELETE /api/oauth2/remove/:token)
|
||||
* 在线用户强退(access + refresh 一并拉黑)
|
||||
|
||||
读取方:JWT 中间件每请求 Exists 判定,命中即 401
|
||||
```
|
||||
|
||||
## 令牌刷新与撤销接口
|
||||
|
||||
```text
|
||||
POST /api/oauth2/token(公开)Refresh:
|
||||
* grant_type=password 或无 refresh_token → 兼容密码模式,复用 Login
|
||||
* refresh_token 模式:
|
||||
- Parse 校验 + typ 必须为 refresh(1008)
|
||||
- 旧 refresh 已在黑名单 → 1008(防重放)
|
||||
- 旧 refresh 入黑名单(轮换)
|
||||
- 签发新 access+refresh,MarkOnline 刷新在线登记
|
||||
- 返回同登录格式四字段
|
||||
|
||||
GET /api/oauth2/check/token 登录可用;JWT 中间件通过即 {valid:true, userId}
|
||||
DELETE /api/oauth2/remove/:token 登录可用;解析指定 token 的 jti 入黑名单
|
||||
GET /api/logout 当前 jti 入黑名单 + MarkOffline + 登录日志(log_type=2)
|
||||
```
|
||||
|
||||
## 登录响应格式(OAuth2 惯例,命名例外)
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIs…",
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIs…",
|
||||
"expires_in": 7200,
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
```
|
||||
|
||||
::: warning 命名例外
|
||||
这是全系统唯一使用下划线命名的响应(其余入出参一律驼峰)。前端按 `data.access_token` 读取,**勿改驼峰**。
|
||||
:::
|
||||
|
||||
## 安全设计要点
|
||||
|
||||
+ HS256 + `WithValidMethods` 限定,防算法混淆攻击(none/RS256 降级)
|
||||
+ exp 必填强校验;iss 与配置比对
|
||||
+ 双令牌分工明确:access 不能当 refresh 用(typ 校验)
|
||||
+ 黑名单 TTL 精确等于剩余有效期,存储无冗余
|
||||
+ 无服务端 Cookie 会话——会话固定攻击面不存在
|
||||
+ 配置项:`jwt.secret`(生产必须改)、`jwt.issuer`、`accessTokenExpire`、`refreshTokenExpire`
|
||||
@@ -0,0 +1,69 @@
|
||||
# 5.15 登录日志
|
||||
|
||||
:::tip 概述
|
||||
登录日志由业务写入(logic/auth.go writeLoginLog):登录成功、失败、退出全记录,含 IP 归属地(ip2region)、操作系统与浏览器解析、耗时统计。失败记录含具体原因,供安全审计。
|
||||
:::
|
||||
|
||||
## 写入时机
|
||||
|
||||
| 时机 | log_type | status | 说明 |
|
||||
|------|----------|--------|------|
|
||||
| 登录成功 | 1 | 0 | 含双令牌签发后的完整信息 |
|
||||
| 登录失败 | 1 | 1 | error 存失败原因;code 参数脱敏 *** |
|
||||
| 退出登录 | 2 | 0 | GET /api/logout 触发 |
|
||||
|
||||
每一步失败(验证码/用户/状态/租户/密码)均写日志——登录闭环的 8 步全路径覆盖。
|
||||
|
||||
## 记录字段
|
||||
|
||||
```text
|
||||
username / log_type(1登录/2退出)/ source(0 后台)
|
||||
method / request_method / url / param(code 脱敏 ***)
|
||||
result(成功|失败)
|
||||
ip / location(ip2region 归属地)
|
||||
os / browser(ua 解析)
|
||||
consume_time(登录耗时)
|
||||
status(0 成功/1 失败)/ error(失败原因)
|
||||
```
|
||||
|
||||
## IP 归属地(utility/ip2region)
|
||||
|
||||
```text
|
||||
xdb 懒加载(sync.Once)+ 读锁并发
|
||||
City(ip) 优先市回退省;内网/回环返回空
|
||||
Location(ip) 供日志(失败显示 "-")
|
||||
路径配置:ip2region.path(默认 manifest/data/ip2region.xdb)
|
||||
|
||||
受信代理:仅直连来源在 api.proxyIps(精确 IP / CIDR)内才信任
|
||||
X-Forwarded-For / X-Real-IP —— 否则日志 IP 是反代 IP
|
||||
```
|
||||
|
||||
OS/Browser 由 utility/ua 解析 User-Agent。
|
||||
|
||||
## 查询模块(/api/login/log)
|
||||
|
||||
```text
|
||||
仅查询与删除(五件):
|
||||
GET /api/login/log/page|list|detail/:id
|
||||
DELETE /api/login/log/delete/:id | /batchDelete
|
||||
权限:sys:loginLog:{page|list|detail|delete|batchDelete}
|
||||
清理:LogClean 任务(job.logRetentionDays 默认 90 天)
|
||||
```
|
||||
|
||||
## 安全审计用法
|
||||
|
||||
```text
|
||||
暴力破解排查:按 ip 聚合 status=1 的失败记录(配合 authLimit=10/分钟/IP)
|
||||
撞库识别:同 IP 多 username 失败尝试
|
||||
异常登录:location 与常用地对比(ip2region 归属地)
|
||||
账号盗用:同账号异地短时间成功登录
|
||||
```
|
||||
|
||||
## 排障
|
||||
|
||||
| 症状 | 检查 |
|
||||
|------|------|
|
||||
| location 显示 "-" | ip2region.xdb 缺失/路径错;内网 IP 本就无归属地 |
|
||||
| location 是机房 IP | api.proxyIps 未含反代出口(日志记了反代 IP) |
|
||||
| os/browser 显示未知 | User-Agent 异常或为空(非浏览器客户端) |
|
||||
| 失败日志无 error | 应有失败原因——检查 writeLoginLog 各分支 |
|
||||
@@ -0,0 +1,84 @@
|
||||
# 5.14 操作日志
|
||||
|
||||
:::tip 概述
|
||||
操作日志由中间件异步采集:gmeta log/logType 标签声明记录内容,OperationLog 中间件组装(含脱敏),有界通道 + 单 worker 批量落库,绝不阻塞业务。41 种日志类型固定枚举。
|
||||
:::
|
||||
|
||||
## 采集链路
|
||||
|
||||
```text
|
||||
gmeta log 标签 → 路由元数据(meta 表)→ 中间件组装 OperationLogEntry
|
||||
→ 有界通道 2048(满则丢弃并计数告警,不阻塞业务)
|
||||
→ 单 worker 攒批(满 100 条或每 1s flush 一批)
|
||||
→ LogSink(operation_log 模块 init 注册的落库函数)
|
||||
→ INSERT goframe_operation_log
|
||||
```
|
||||
|
||||
## 记录字段
|
||||
|
||||
```text
|
||||
Title=log 标签 Type=logType Source=0
|
||||
Method="GET /api/user/page"(路由模板) RequestMethod/URL=实际请求
|
||||
Param:GET/DELETE 取 query JSON;写操作优先取 body
|
||||
(maskSecret 脱敏 password/oldPassword/newPassword/salt/
|
||||
access_token/refresh_token → ***)
|
||||
Result:响应信封 JSON(截断 8000 字节,UTF-8 边界回退截断)
|
||||
IP=realClientIP Location=ip2region OS/Browser=ua 解析
|
||||
ConsumeTime=毫秒 Status:响应 code==0 → 0 正常,否则 1 异常(Error=msg)
|
||||
CreateUser=ctx 用户名
|
||||
```
|
||||
|
||||
## log_type 固定枚举(consts/log_type.go,数值勿改)
|
||||
|
||||
```text
|
||||
0=自动推断(GET→查询,其余→修改)
|
||||
1=新增 2=修改 3=删除 4=查询 5=导入 6=导出
|
||||
7=审批 8=驳回 9=提交 10=撤回 11=关闭 12=开启
|
||||
13=启用 14=禁用 15=上传 16=下载 17=发送 18=同步
|
||||
19=代码生成 20=清空 21=还原 22=登录 23=登出 24=重置
|
||||
25=分配 26=授权 27=执行 28=暂停 29=恢复 30=发布
|
||||
31=下架 32=复制 33=移动 34=排序 35=锁定 36=解锁
|
||||
37=归档 38=取消归档 39=绑定 40=解绑 99=其他
|
||||
```
|
||||
|
||||
gmeta `logType` 字符串(query/add/update/delete/import/export/reset/…)经 LogTypeMap 转枚举;展示中文经 LogTypeNames(logic 的 LogTypeText)——**该字段不走字典**(数值与注解绑定,改动破坏存量日志)。
|
||||
|
||||
## 开关与配置
|
||||
|
||||
```yaml
|
||||
operationLog:
|
||||
enable: true # 总开关
|
||||
logQuery: true # 是否记录查询类操作(logType=query)
|
||||
```
|
||||
|
||||
日志标题约定:`{模块中文名}-{动作}`(如"用户管理-查询分页记录"),动作映射 page=查询分页记录 / list=查询列表 / detail=查询详情 / add=新增记录 / update=修改记录 / delete=删除记录 / batchDelete=批量删除记录 / import=导入数据 / export=导出数据 / status=设置状态。
|
||||
|
||||
## 查询模块(/api/oper/log)
|
||||
|
||||
```text
|
||||
仅查询与删除(无增改——日志只能系统写入):
|
||||
GET /api/oper/log/page|list|detail/:id
|
||||
DELETE /api/oper/log/delete/:id | /batchDelete
|
||||
权限:sys:operLog:{page|list|detail|delete|batchDelete}
|
||||
清理:内置 LogClean 定时任务物理删除超保留期记录(job.logRetentionDays 默认 90 天)
|
||||
```
|
||||
|
||||
## 可靠性设计
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| 通道满(2048) | 丢弃该条并计数,每丢 100 条输出一次 Warning——**日志绝不阻塞业务** |
|
||||
| 落库失败 | 仅记错误日志,不影响响应 |
|
||||
| panic | Response 中间件 recover 兜底(日志采集在 defer 后仍执行) |
|
||||
| 批量节奏 | 满 100 条或 1s flush——峰值下落库延迟 ≤1s |
|
||||
|
||||
## 排障
|
||||
|
||||
```text
|
||||
日志完全缺失:operationLog.enable=false?LogSink 未注册(启动告警)?
|
||||
查询类没记录:operationLog.logQuery=false
|
||||
偶发缺失:通道满丢弃告警(看 Warning 日志);峰值场景可调大通道容量
|
||||
Param 是 ***:正常——敏感字段脱敏
|
||||
Result 超长被截:正常——截断 8KB
|
||||
IP/归属地不对:api.proxyIps 未含反代地址(见受信代理机制)
|
||||
```
|
||||
@@ -0,0 +1,55 @@
|
||||
# 5.13 系统参数管理
|
||||
|
||||
:::tip 概述
|
||||
参数模块(goframe_param)管理系统级键值参数:code 唯一标识 + name + value。与 config 模块的区别在于形态——param 是扁平键值对,适合"每页条数、默认密码、上传目录名"这类全局参数。
|
||||
:::
|
||||
|
||||
## 数据结构
|
||||
|
||||
```text
|
||||
goframe_param:code(uk_code 全局唯一)/ name / value / remark / sort
|
||||
|
||||
与 config 的选择:
|
||||
需要分组、类型控件、选项 → config + config_item(结构化配置)
|
||||
只是简单键值对 → param(扁平参数)
|
||||
```
|
||||
|
||||
## 接口清单
|
||||
|
||||
```text
|
||||
param(/api/param)七件套:sys:param:{page|list|detail|add|update|delete|batchDelete}
|
||||
```
|
||||
|
||||
标准 CRUD,无扩展接口。业务侧按 code 查询取值。
|
||||
|
||||
## 典型使用场景
|
||||
|
||||
| 参数 | code 示例 | 说明 |
|
||||
|------|----------|------|
|
||||
| 系统默认密码 | default_password | user.ResetPwd 未传新密码时使用 |
|
||||
| 每页默认条数 | page_size | 前端分页默认值 |
|
||||
| 用户编码前缀 | user_code_prefix | genUserCode 生成规则 |
|
||||
| 公告显示条数 | notice_top_n | 首页公告拉取数量 |
|
||||
|
||||
(具体参数随种子数据与业务演进,以库中实际为准。)
|
||||
|
||||
## 业务消费模式
|
||||
|
||||
```go
|
||||
// 模式:按 code 查询(低频,直接查库;高频场景应加缓存)
|
||||
row, _ := dao.Param.Ctx(ctx).Where("code", "default_password").One()
|
||||
value := row["value"].String()
|
||||
```
|
||||
|
||||
::: tip 与配置文件的选择判据
|
||||
+ 需要重启才生效、涉及连接/密钥/开关 → manifest/config.yaml
|
||||
+ 运营随时改、改了立即生效、结构化(分组/类型/选项) → config 模块
|
||||
+ 运营随时改、简单键值对 → param 模块
|
||||
:::
|
||||
|
||||
## 管理要点
|
||||
|
||||
+ code 全局唯一(uk_code),命名用 snake_case 语义化(`xxx_yyy`)
|
||||
+ 平台级表(无租户隔离)——租户参数不在此设计内
|
||||
+ 修改即时生效(无缓存层);调用方按需自建缓存
|
||||
+ 删除前确认无业务引用(param 无引用守卫,删除是软删可恢复)
|
||||
@@ -0,0 +1,79 @@
|
||||
# 5.6 密码加密服务
|
||||
|
||||
:::tip 概述
|
||||
密码加密由 utility/crypto 实现:bcrypt(password+salt) 双重加盐。外部 salt 存库、bcrypt 内部再加盐,双层防御彩虹表与拖库攻击。旧 md5 数据被 IsBcrypt 识别并拒绝登录(强制重置升级)。
|
||||
:::
|
||||
|
||||
## 加密与校验(utility/crypto)
|
||||
|
||||
| 函数 | 职责 |
|
||||
|------|------|
|
||||
| GenerateSalt | 生成 8 字节随机盐,base64url 编码截 10 位 |
|
||||
| HashPassword | bcrypt(password+salt),Cost=DefaultCost |
|
||||
| VerifyPassword | bcryptHash 与 password+salt 比对 |
|
||||
| IsBcrypt | 识别是否 bcrypt 格式(`$2a$`/`$2b$` 前缀) |
|
||||
|
||||
## 双重加盐设计
|
||||
|
||||
```text
|
||||
存储形态:user 表的 password 列(bcrypt 哈希)+ salt 列(外部盐)
|
||||
|
||||
加密:HashPassword(明文, salt) = bcrypt(明文 + salt)
|
||||
校验:VerifyPassword(存储哈希, 明文, salt)
|
||||
→ 内部计算 bcrypt(明文 + salt) 与存储哈希比对
|
||||
|
||||
为什么双重加盐:
|
||||
* bcrypt 自带 cost 迭代 + 内部盐 → 防彩虹表、抗暴力
|
||||
* 外部 salt 存库 → 即使哈希算法被攻破,攻击者还需拿到 salt 列
|
||||
* 同密码不同用户哈希不同 → 拖库后无法横向碰撞
|
||||
```
|
||||
|
||||
## 密码路径全景
|
||||
|
||||
| 路径 | 行为 |
|
||||
|------|------|
|
||||
| 用户新增(user.Add) | GenerateSalt + HashPassword(**事务外预计算**),salt 与 password 同插入 |
|
||||
| 用户修改密码(index.UpdatePassword) | 旧密码校验(1012 原密码错误)→ 新密码 bcrypt → 更新 |
|
||||
| 重置密码(user.ResetPwd) | 未传新密码时重置为系统默认密码;管理员操作,logType=reset |
|
||||
| 用户导入(user.Import) | 未提供密码的行使用默认密码统一 bcrypt |
|
||||
| 登录(auth.Login) | VerifyPassword 比对;旧 md5 数据被 IsBcrypt 拒绝 |
|
||||
|
||||
## 事务纪律(重要)
|
||||
|
||||
::: warning bcrypt 必须在事务外预计算
|
||||
bcrypt 是 CPU 密集操作(DefaultCost 约 6-10ms/次)。事务内做 bcrypt 会拉长行锁持有时间。项目纪律:
|
||||
|
||||
```text
|
||||
❌ WithTrans(ctx, func(ctx) error {
|
||||
hash := HashPassword(...) // CPU 密集在事务内
|
||||
dao.User.Ctx(ctx).Data(...).Insert()
|
||||
})
|
||||
|
||||
✅ hash := HashPassword(...) // 事务外预计算
|
||||
WithTrans(ctx, func(ctx) error {
|
||||
dao.User.Ctx(ctx).Data(...).Insert() // 事务内只做 DB 操作
|
||||
})
|
||||
```
|
||||
|
||||
租户开户(tenant.Account)与用户新增(user.Add)均遵循此纪律;事务内同时禁止 HTTP/文件 IO。
|
||||
:::
|
||||
|
||||
## 旧数据兼容(md5 → bcrypt 升级)
|
||||
|
||||
```text
|
||||
IsBcrypt(存储值):
|
||||
true → 正常 bcrypt 比对
|
||||
false → 旧 md5 数据:登录直接拒绝,提示重置密码
|
||||
```
|
||||
|
||||
拒绝而非自动迁移的原因:md5 无盐(或弱盐)数据无法安全地"验旧写新",强制重置是更安全的升级路径。
|
||||
|
||||
## 安全清单
|
||||
|
||||
```text
|
||||
* bcrypt DefaultCost(框架默认,够用;更高安全场景可显式提升 cost)
|
||||
* salt 为独立列,永不返回接口(GetUser/Detail 均不含 password/salt)
|
||||
* 操作日志脱敏:param 中 password/oldPassword/newPassword/salt → ***
|
||||
* 修改密码走旧密码校验(本人)或 ResetPwd(管理员,留审计)
|
||||
* 无独立复杂度策略——业务层未强制,可按需在密码路径追加规则
|
||||
```
|
||||
@@ -0,0 +1,99 @@
|
||||
# 5.4 权限管理(RBAC)
|
||||
|
||||
:::tip 概述
|
||||
RBAC(基于角色的访问控制)链路:user →(user_role)→ role →(role_menu)→ menu(type=1 权限节点)。身份在 JWT 中间件加载(缓存 300s),权限判定在 Bind 包装层(HasPerm),前端按通配权限放开按钮。
|
||||
:::
|
||||
|
||||
## RBAC 授权链路图
|
||||
|
||||
```text
|
||||
请求 ──▶ JWT中间件 ──▶ LoadIdentity(userID)
|
||||
│
|
||||
user ─────────┤ dept_id → DeptId(数据权限用)
|
||||
user_role ────┤ role.code → Roles[]
|
||||
│ role.data_scope → DataScope(多角色取最宽松)
|
||||
role_menu ────┤
|
||||
menu(type=1, ┤ permission → Perms[]
|
||||
status=0, │
|
||||
hide=0) ────┘
|
||||
▼
|
||||
缓存 easygoadmin:user:identity:{uid} JSON TTL 300s
|
||||
▼
|
||||
Bind 包装层 HasPerm("sys:user:page"):
|
||||
permission=="" → 通过 │ Roles 含 ROLE_SUPER_ADMIN → 通过
|
||||
│ Perms 精确匹配 → 通过 │ 否则 403
|
||||
数据范围经 ctx(WithDataScope/WithDeptID)随行,在 applyFilters 末环过滤
|
||||
缓存失效:角色保存权限/改数据范围 / 用户分配角色/改部门 等主动 Del,过期兜底
|
||||
```
|
||||
|
||||
## 身份加载(LoadIdentity,缓存 300s)
|
||||
|
||||
```text
|
||||
1) user 行回查 dept_id → DeptId(单列查询)
|
||||
2) user_role → roleIds → role.code 集合(Roles)
|
||||
同一查询顺带读 role.data_scope:多角色取最宽松(数值 min,
|
||||
1 最宽 4 最严;缺列值按"全部"参与比较)→ DataScope
|
||||
3) role_menu → menuIds → menu 中 type=1 且 status=0 且 hide=0 的
|
||||
permission 非空去重集合(Perms)
|
||||
4) 超管/平台:DataScope 强制"全部"(过滤层另有 dataScopeBypass 双保险)
|
||||
```
|
||||
|
||||
## 权限判定(consts.HasPerm)
|
||||
|
||||
```text
|
||||
判定顺序:
|
||||
1. permission 标签为空 → 通过(仅登录即可,如 /api/index/*)
|
||||
2. Roles 含 ROLE_SUPER_ADMIN → 通过(超管全放行)
|
||||
3. Perms 精确匹配 → 通过
|
||||
4. 否则 → code=403 信封
|
||||
|
||||
权限码规范:sys:{模块小驼峰}:{action}
|
||||
前端识别超管通配权限 *:*:*(getUser 返回)放开全部按钮权限。
|
||||
```
|
||||
|
||||
## 权限点声明与管理
|
||||
|
||||
| 环节 | 实现 |
|
||||
|------|------|
|
||||
| 声明 | 接口 gmeta `permission:"sys:user:page"` 标签 |
|
||||
| 存储 | goframe_menu 表 type=1 行(permission 列) |
|
||||
| 创建 | 生成器自动创建(7 固定 + status/import/export 按需)或菜单管理手工 |
|
||||
| 分配 | 角色管理勾选 → POST /api/role/menu/save 重写 role_menu + 清身份缓存 |
|
||||
| 判定 | Bind 包装层 HasPerm(每请求) |
|
||||
|
||||
角色管理扩展接口:
|
||||
|
||||
```text
|
||||
GET /api/role/menu/list/:roleId 查询角色已勾选菜单(sys:role:getPermission)
|
||||
POST /api/role/menu/save 重写 role_menu + 清理相关用户身份缓存
|
||||
(sys:role:savePermission)
|
||||
```
|
||||
|
||||
菜单树:GetUserMenuTree 由 user→role→role_menu→menu 组装(index.GetMenus 消费);超管返回全量菜单。
|
||||
|
||||
## 缓存失效链
|
||||
|
||||
```text
|
||||
身份缓存 easygoadmin:user:identity:{uid} 删除时机:
|
||||
* role.SavePermission → clearRoleIdentityCache(roleId)
|
||||
→ 查该角色全部 user → 逐个 Del
|
||||
* role.Update(含 data_scope 变更)→ AfterUpdate → 同上
|
||||
* user.AssignRoles(user.Update/Add 均经此,改部门亦然)
|
||||
→ clearIdentityCache(userId)
|
||||
* 兜底:300s 自然过期
|
||||
```
|
||||
|
||||
## 前端配合
|
||||
|
||||
+ 权限指令(directives/permission.ts)按 getUser 返回的 permissions 控制按钮显隐
|
||||
+ 超管返回 `*:*:*`,指令识别通配直接放行
|
||||
+ 后端不做按钮级校验——按钮级是前端 UX 控制,接口级是后端强制(**新增接口必须声明 permission 或评审确认留空**)
|
||||
|
||||
## 安全注意
|
||||
|
||||
```text
|
||||
* hide=1 的权限节点不进 Perms——"禁用权限"用 status,不能用 hide
|
||||
* 权限留空 = 登录即可,评审需明确该选择(新接口默认应声明权限)
|
||||
* 身份缓存 300s 窗口内直接改库不会即时生效(API 路径均主动清缓存)
|
||||
* 超管绕过全部权限校验——平台管理端点的收紧依赖节点设计
|
||||
```
|
||||
@@ -0,0 +1,105 @@
|
||||
# 5.8 多租户隔离
|
||||
|
||||
:::tip 概述
|
||||
多租户是硬隔离:9 张租户级表的 tenant_id 过滤与盖章由全局钩子强制执行,业务代码零感知、不可绕过。租户状态与过期由 Tenant 中间件每请求校验(60s 缓存)。
|
||||
:::
|
||||
|
||||
## 数据模型
|
||||
|
||||
```text
|
||||
goframe_tenant:code(T+4位ID,Account 生成)、status(1 正常/2 禁用)、
|
||||
expire_time(nil=永不过期)、max_user_num 等
|
||||
|
||||
9 张租户级表(带 tenant_id 列,hooks.tenantTables 登记):
|
||||
goframe_user / role / dept / level / position / article / category / notice / link
|
||||
|
||||
平台级(无租户过滤):其余 16 张表
|
||||
```
|
||||
|
||||
## 读侧隔离(不可绕过)
|
||||
|
||||
```text
|
||||
dao.Ctx(hooks.ApplyCtx):非平台上下文自动追加
|
||||
WHERE tenant_id IN (0, 当前租户) ← 0 = 平台共享行
|
||||
|
||||
超管(ctx tenant_id=0):不加过滤,全量可见
|
||||
写侧禁止手写该条件——dao.Ctx 是唯一查询入口
|
||||
```
|
||||
|
||||
## 写侧归属(insertHook 盖章规则)
|
||||
|
||||
```text
|
||||
租户用户插入未显式指定 tenant_id → 自动填自身(防逃逸)
|
||||
超管未显式指定 → 填 tenant.defaultId(默认 1)
|
||||
显式指定具体值 → 从显式值
|
||||
显式 0 → 创建平台共享行(如全局字典/参数)
|
||||
|
||||
业务取归属租户统一 consts.GetTenantID(ctx)(超管归默认租户);
|
||||
CtxTenantID(ctx) 原始值仅供租户设施使用。
|
||||
```
|
||||
|
||||
## 请求级校验(Tenant 中间件)
|
||||
|
||||
```text
|
||||
位于 JWT 之后:
|
||||
* 公开路径放行
|
||||
* ctx tenant_id=0(平台/超管)直接放行
|
||||
* 其余加载租户摘要(缓存 easygoadmin:tenant:info:{id},TTL 60s):
|
||||
- status != 1 → code=1006 租户已禁用
|
||||
- expire_time 早于现在 → code=1005 租户已过期
|
||||
|
||||
LoadTenant 语义:租户不存在 → 按禁用处理(失效关闭);
|
||||
数据库基础错误 → 返回 nil,中间件放行(避免存储抖动导致全站不可用)
|
||||
```
|
||||
|
||||
## 租户状态机
|
||||
|
||||
```text
|
||||
Account 开户
|
||||
│
|
||||
▼
|
||||
┌─────────┐ Status(2) ┌─────────┐
|
||||
│ 1 正常 │─────────────▶│ 2 禁用 │
|
||||
└─────────┘ └─────────┘
|
||||
│ │
|
||||
│ expire_time 到期 │ Status(1)
|
||||
▼ │
|
||||
过期(1005 拒绝访问)◀──────────┘
|
||||
(expire_time nil = 永不过期)
|
||||
```
|
||||
|
||||
状态/过期变更后 tenant:info:{id} 缓存由 tenant 模块 AfterUpdate clearCache 钩子清理。
|
||||
|
||||
## 租户开户(tenant.Account,POST /api/tenant/account)
|
||||
|
||||
```text
|
||||
创建租户 + 初始管理员:事务内
|
||||
1) 建租户(code 按 T+ID 规则生成)
|
||||
2) 建管理员用户(bcrypt 事务外预计算)
|
||||
3) 分配角色
|
||||
|
||||
max_user_num 上限校验(1007)在 user.Add 生效(checkTenantUserMax)
|
||||
事务纪律:事务内不做 HTTP/文件 IO,bcrypt 等 CPU 计算事务外预计算
|
||||
```
|
||||
|
||||
配套接口:`GET /api/tenant/users/:tenantId` 查询租户用户(sys:tenant:users)。
|
||||
|
||||
## 安全审计要点
|
||||
|
||||
::: warning 租户红线
|
||||
1. 业务代码禁止 `g.DB()` 直查业务表
|
||||
2. 业务代码禁止手写 del_flag / tenant_id 条件
|
||||
3. **新增租户级表必须登记 hooks.tenantTables(漏登=跨租户泄漏)**
|
||||
4. 唯一预检范围必须等于唯一索引范围(含 tenant_id 索引须 Group:"tenant_id")
|
||||
5. 平台级全量可见是设计预期,管理端点须收紧 permission
|
||||
:::
|
||||
|
||||
## 排障
|
||||
|
||||
| 症状 | 原因 |
|
||||
|------|------|
|
||||
| 租户看到别家数据 | tenantTables 漏登记该表;或业务绕过 dao.Ctx |
|
||||
| 租户看不到平台共享行 | 业务手写了 `tenant_id = ?`(应交给 dao.Ctx 的 IN(0,x)) |
|
||||
| 禁用租户仍能访问 | 等 60s 缓存过期,或 DEL `easygoadmin:tenant:info:{id}` |
|
||||
| 新增报租户用户数上限 | 1007——超过 max_user_num |
|
||||
| 超管新增数据归属不对 | 显式指定 tenantId 或调 tenant.defaultId 配置 |
|
||||
Reference in New Issue
Block a user