================================================================================ 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)