218 lines
14 KiB
Plaintext
218 lines
14 KiB
Plaintext
================================================================================
|
||
EasyGoAdmin 后端开发者文档 · 02 中间件与请求处理链
|
||
================================================================================
|
||
代码位置:internal/middleware/{cors,response,ratelimit,demo,jwt,tenant,
|
||
operation_log,public,util,wire,log}.go
|
||
|
||
注册顺序(router.go s.Use,顺序即执行顺序):
|
||
|
||
CORS → Response → RateLimit → Demo → JWT → Tenant → OperationLog → 处理器
|
||
(Bind 包装层做权限校验)
|
||
|
||
--------------------------------------------------------------------------------
|
||
1. CORS(cors.go)
|
||
--------------------------------------------------------------------------------
|
||
读取 cors.allowOrigin:
|
||
空或 "*" → r.Response.CORSDefault()(框架内置全放行策略)
|
||
具体来源 → 显式回显 Access-Control-Allow-Origin/Credentials/Headers/Methods,
|
||
OPTIONS 预检直接 204 返回。
|
||
Allow-Headers:Origin,Authorization,Content-Type,Accept,Token,X-Requested-With
|
||
Allow-Methods:GET,POST,PUT,DELETE,OPTIONS
|
||
|
||
--------------------------------------------------------------------------------
|
||
2. Response(response.go)—— 统一响应兜底
|
||
--------------------------------------------------------------------------------
|
||
职责一:SVG 静态文件加固。路径以 .svg 结尾时强制
|
||
Content-Disposition: attachment + X-Content-Type-Options: nosniff,
|
||
防止上传的 SVG 被浏览器同源内嵌执行(存储型 XSS 防线)。
|
||
职责二:请求元信息写入 ctx(realClientIP / User-Agent / Method / URL),
|
||
供登录日志与操作日志采集(consts.WithRequestMeta)。
|
||
职责三:panic 恢复。defer recover() 记录堆栈日志;若尚未写出响应则输出
|
||
500 统一信封(CodeServerError);已写出则只记录,避免双写。
|
||
|
||
--------------------------------------------------------------------------------
|
||
3. RateLimit(ratelimit.go)—— 固定窗口限流
|
||
--------------------------------------------------------------------------------
|
||
开关与阈值(manifest/config):
|
||
rateLimit.enable 默认 true
|
||
rateLimit.windowSeconds 窗口秒数,默认 60
|
||
rateLimit.limit 普通接口每窗口每 IP 每路径上限,默认 120(0=关闭普通限流)
|
||
rateLimit.authLimit 认证入口独立更严阈值,默认 10
|
||
|
||
规则:
|
||
* 仅限 /api 前缀路径;/api/health 显式豁免;/api.json、/swagger、静态资源
|
||
不带 /api/ 前缀自然不受限。
|
||
* 认证入口(/api/login、/api/captcha、/api/oauth2/token)用 authLimit。
|
||
* 键:ratelimit:{clientIP}:{path}:{窗口起点},窗口起点 = Unix/window*window
|
||
对齐;TTL = window+5s(仅用于回收,不影响窗口归属)。
|
||
* 计数经 utility/cache(Redis 优先、内存降级)→ 多实例共享计数。
|
||
* 计数存储异常时放行(限流不阻断业务);超限返回 code=429 信封。
|
||
* incrCounter:GET→SET 自增;不存在时 SET NX 抢占;并发下 NX 失败再读现值加一。
|
||
|
||
真实 IP 取值(util.go realClientIP):
|
||
直连来源不在 api.proxyIps(逗号分隔,支持精确 IP 与 CIDR)内 → 直接用
|
||
GetRemoteIp();在列表内才信任 X-Forwarded-For 首段 / X-Real-IP。
|
||
防止伪造 XFF 绕过限流或污染日志。
|
||
|
||
--------------------------------------------------------------------------------
|
||
4. Demo(demo.go)—— 演示模式
|
||
--------------------------------------------------------------------------------
|
||
配置 app.demo=true 时启用:
|
||
* GET/HEAD/OPTIONS 放行(演示站可浏览)
|
||
* 公开白名单路径放行(保证演示站可登录)
|
||
* 其余写操作(POST/PUT/PATCH/DELETE)一律拒绝:
|
||
code=1015 “演示环境,禁止操作!”
|
||
生产环境保持 app.demo=false。
|
||
|
||
--------------------------------------------------------------------------------
|
||
5. JWT(jwt.go)—— 身份认证
|
||
--------------------------------------------------------------------------------
|
||
流程:
|
||
1) IsPublicPath 命中 → 放行(登录/验证码/令牌兑换/健康检查/文档)
|
||
2) 取 token:Authorization: Bearer xxx(兼容小写与无前缀),
|
||
缺失时回退查询参数 ?token=(个别 URL 直传场景)
|
||
3) jwtutil.Parse:HS256 + 签名/过期/签发者(jwt.issuer)校验,
|
||
非 HMAC 或无效 → 401
|
||
4) 黑名单检查:cache.Exists(easygoadmin:token:blacklist:{jti}) → 401
|
||
(登出、令牌轮换、强制下线写入)
|
||
5) ctx 注入:UserID/TenantID/Username/Realname/TokenJTI/TokenExpire
|
||
6) 身份扩展(角色+权限+数据范围):优先读缓存 easygoadmin:user:identity:{userID}
|
||
(JSON,TTL 300s),未命中回源 IdentityLoader(= logic.LoadIdentity)并回写。
|
||
注入 ctx:Roles/Perms + DataScope(数据范围档位)/DeptID(部门ID),
|
||
后两者供数据权限过滤读取(见 03 章第 6 节)。
|
||
加载失败不阻断请求(告警日志),回退空身份(等价无权限;数据范围
|
||
GetDataScope 读 0 回退"全部"——无权限下列表本就不可达)。
|
||
|
||
loadIdentity 说明:userID<=0 直接跳过;IdentityLoader 为 nil(未接线)时
|
||
返回空身份。
|
||
|
||
--------------------------------------------------------------------------------
|
||
6. Tenant(tenant.go)—— 租户状态校验
|
||
--------------------------------------------------------------------------------
|
||
位于 JWT 之后:
|
||
* 公开路径放行
|
||
* ctx tenant_id=0(平台/超管)直接放行
|
||
* 其余加载租户摘要(缓存 easygoadmin:tenant:info:{id},TTL 60s,未命中
|
||
回源 TenantLoader = logic.LoadTenant):
|
||
- status != 1 → code=1006 租户已禁用
|
||
- expire_time 早于现在 → code=1005 租户已过期
|
||
* LoadTenant 语义:租户不存在 → 按禁用处理(失效关闭);数据库基础错误 →
|
||
返回 nil,中间件放行(避免存储抖动导致全站不可用)。
|
||
|
||
--------------------------------------------------------------------------------
|
||
7. OperationLog(operation_log.go)—— 操作日志采集
|
||
--------------------------------------------------------------------------------
|
||
开关:operationLog.enable(总开关);operationLog.logQuery(是否记录查询类)。
|
||
|
||
流程:请求前记 start → Next() → 从 ctx 取路由元数据(meta.CtxKey),
|
||
无 log 标签直接跳过 → logType=LogTypeMap[rm.LogType](query 且 logQuery=false
|
||
跳过)→ 组装 OperationLogEntry:
|
||
|
||
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 用户名
|
||
|
||
异步落库:有界通道(2048)+ 单 worker;批量满 100 条或每 1s flush 一批,
|
||
逐条经 LogSink(= 业务侧注册的落库函数)写 goframe_operation_log;
|
||
落库失败仅记错误日志;通道满则丢弃(每丢 100 条告警一次),绝不阻塞业务。
|
||
|
||
--------------------------------------------------------------------------------
|
||
8. 免登录白名单(public.go)
|
||
--------------------------------------------------------------------------------
|
||
publicPaths 精确匹配:
|
||
/api/login、/api/register(白名单放行,路由未注册)、/api/captcha、/api/oauth2/token、/api/health
|
||
另放行 /api.json 与 /swagger 前缀(文档)。
|
||
|
||
三处须保持一致(改动时同步):
|
||
1) middleware/public.go 白名单
|
||
2) 各接口 gmeta 的 public:"true" 标注(auth.go)
|
||
3) 健康检查等 router.go 中的手工 BindHandler
|
||
|
||
注意:public 路由不带 /v1 版本段的历史约定已固化为“全部路由都不带 /v1”,
|
||
白名单与 gmeta path 均以 /api 直达。
|
||
|
||
--------------------------------------------------------------------------------
|
||
9. 依赖接线(wire.go)
|
||
--------------------------------------------------------------------------------
|
||
中间件层不 import 业务包,通过三个函数变量解耦:
|
||
|
||
var IdentityLoader func(ctx, userID) (*Identity, error) // 角色+权限
|
||
var TenantLoader func(ctx, tenantID) (*TenantInfo, error) // status/expire
|
||
var LogSink func(ctx, *OperationLogEntry) error // 日志落库
|
||
|
||
注册方(logic 各文件 init):
|
||
internal/logic/auth.go init: SetIdentityLoader(LoadIdentity)
|
||
SetTenantLoader(LoadTenant)
|
||
internal/logic/operation_log.go init: SetLogSink(SaveOperationLog)
|
||
(异步 worker 每条回调,写 goframe_operation_log)
|
||
|
||
Identity 结构:{Roles []string(角色编码), Perms []string(权限节点),
|
||
DataScope int(数据范围档位,多角色取最宽松;0=旧缓存缺字段,
|
||
读侧 GetDataScope 回退"全部"), DeptId int(所属部门ID)}
|
||
TenantInfo 结构:{ID, Status, ExpireTime}(热路径最小字段集)
|
||
|
||
cmd/wire.go 在启动时仅校验三者是否非 nil,缺失输出警告(不 panic)。
|
||
|
||
--------------------------------------------------------------------------------
|
||
10. 辅助工具(util.go / log.go)
|
||
--------------------------------------------------------------------------------
|
||
cfgBool/cfgInt 读配置,缺失回退默认值
|
||
gjsonEncode JSON 序列化(失败返回空串)
|
||
trunc 按字节截断并回退到 UTF-8 字符边界(防 MySQL 拒绝非法字节)
|
||
maskJSONField 正则替换 JSON 文本中指定字段值为 ***(正则缓存复用)
|
||
realClientIP 受信代理 + XFF/X-Real-IP
|
||
isTrustedProxy api.proxyIps 精确 IP / CIDR 匹配
|
||
requestLocation ip2region.Location(失败返回 "-")
|
||
glogWarn 中间件统一告警输出
|
||
|
||
--------------------------------------------------------------------------------
|
||
11. 请求生命周期(一个受保护写请求的完整路径)
|
||
--------------------------------------------------------------------------------
|
||
1 CORS 预检/头设置
|
||
2 Response 元信息入 ctx;defer panic 兜底
|
||
3 RateLimit 固定窗口计数,超限 429
|
||
4 Demo 演示模式拦截写操作
|
||
5 JWT 解析校验 token → 注入身份;黑名单 401
|
||
6 Tenant 租户状态/过期校验(60s 缓存)
|
||
7 OperationLog 记 start,Next()
|
||
8 Bind 包装层 元数据入 ctx → HasPerm 权限校验(403)→ Parse 参数 → 调 controller
|
||
9 controller 转发 service 接口
|
||
10 logic ModuleSpec 流水线 / 手写业务;dao.Ctx 查询(钩子已注入条件)
|
||
11 response.Write 信封写出(code/body 记入 ctx)
|
||
12 OperationLog 组装记录 → 异步通道 → 批量落库
|
||
|
||
12. 请求处理时序图(受保护写请求 + 操作日志异步落库)
|
||
|
||
Client 中间件链 Bind包装/业务 缓存 DB/日志
|
||
│ POST /api/user/update │ │ │
|
||
│───▶│ CORS 头/预检 │ │ │
|
||
│ │ Response: 元信息入ctx, defer recover │ │
|
||
│ │ RateLimit ──────────────────────────────────────▶ SET/GET 计数│
|
||
│ │ Demo(app.demo=false 放行) │ │ │
|
||
│ │ JWT: Parse+黑名单 ───────────────────────────────▶ Exists jti │
|
||
│ │ 注入身份; Roles/Perms/DataScope/DeptId ──────▶ 命中缓存 │
|
||
│ │ 未命中 → IdentityLoader 回源 ───────────────────────▶ user/role/menu
|
||
│ │ Tenant: status/expire ───────────────────────────▶ tenant:info │
|
||
│ │ OperationLog: start=now │ │ │
|
||
│ │ │ │ │ │
|
||
│ │ ▼ ▼ HasPerm(403?短路) │ │
|
||
│ │ Parse(req) → logic.Update │ │
|
||
│ │ spec 流水线: BeforeUpdate → │ │
|
||
│ │ saveTransform → 唯一预检 → ────────────────▶ UPDATE
|
||
│ │ (hooks 盖章 update_time/user) │ │
|
||
│ │ ▼ │ │ │
|
||
│◀───│ WriteJson{code:0,...}(code/body 记 ctx) │ │
|
||
│ │ OperationLog: 组装 entry(脱敏 param/result) │ │
|
||
│ │ select{ logChan<-entry } 非阻塞 │ │
|
||
│ │ worker: 攒批 100条/1s ──────────────────▶ INSERT operation_log
|
||
|
||
短路/异常分支:
|
||
* 任一中间件短路(429 / 演示拦截 / 401 / 403)→ 直接 WriteJson,后续不执行
|
||
* panic → Response recover → 500 信封(已写出响应则仅记录日志)
|
||
* logChan 满 → 丢弃并计数告警(每丢 100 条输出一次 Warning)
|