================================================================================ EasyGoAdmin 后端开发者文档 · 01 系统架构设计 ================================================================================ -------------------------------------------------------------------------------- 1. 分层与调用链 -------------------------------------------------------------------------------- main.go(注册五数据库驱动 + Redis 适配器 + blank import internal/logic) └─ internal/cmd.Run(ctx) ├─ 1) utility/cache.Init Redis 探测,失败降级进程内存 ├─ 2) wireDependencies 校验中间件依赖是否已由业务包 init 接线 ├─ 3) g.Server("easygoadmin") + router.Register(s) │ 中间件链:CORS → Response → RateLimit → Demo → JWT → Tenant │ → OperationLog → 处理器 │ 处理器 = Bind 反射包装层(权限校验 → 参数解析 → controller → │ service 接口 → logic 实现) ├─ 4) go DaemonInit(延时 2s) 定时任务调度器加载 status=1 任务 ├─ 5) gcron 注册 CleanTemp 每日 03:30 清理上传临时目录 └─ 6) s.Run() 阻塞运行 HTTP 服务 业务调用链(标准 CRUD): Bind 包装(gmeta 权限/日志元数据) → controller.CXxx(薄层,仅转发 service.Xxx()) → service.IXxx 接口(internal/service/{模块}.go,手维护) → logic.sXxx 实现(internal/logic/{模块}.go,ModuleSpec 基座或手写) → dao.Xxx.Ctx(ctx)(唯一查询入口:挂全局钩子 + 注入 del_flag/租户条件) → gdb → 数据库 关键设计决策: * 目录平铺:api/v1、controller、logic、service 均为单包,每模块一个 snake_case 文件;跨模块直接同包调用(如 user 直接调用包级函数 ResolveCityNames,无需 import)。 * service 接口手维护:internal/service/{模块}.go 声明接口 + Register 注册函数 + 包级取用函数(如 service.City())。禁止整体运行 gf gen service(会覆盖手工 重组的接口文件)。 * logic 同包 init() 注册:每个 logic 文件在 init() 里调用 service.RegisterXxx 完成依赖接线,无需 blank import,包内全量加载。 * 中间件不依赖业务层:身份/租户加载器与日志落地器以函数变量形式注册 (internal/middleware/wire.go),由 logic 的 init 注入(auth.go 的 init 注册 IdentityLoader=LoadIdentity、TenantLoader=LoadTenant;operation_log 的 LogSink 由日志模块注册)。cmd/wire.go 仅在启动期检查并告警缺失。 1.1 系统总架构图 ┌───────────────────────────── 客户端 ─────────────────────────────┐ │ ui/(Vue3 + Element Plus,baseURL=/api) 其他 API 消费方 │ └──────────────────────────────────┬─────────────────────────────────┘ │ HTTP(Bearer JWT) ╔══════════════════════════════════▼═════════════════════════════════╗ ║ ghttp Server(:8000) ║ ║ ┌────────────────── 中间件链(顺序执行)──────────────────┐ ║ ║ │ CORS → Response → RateLimit → Demo → JWT → Tenant │ ║ ║ │ → OperationLog │ ║ ║ └──────────────────────────┬──────────────────────────────┘ ║ ║ ┌──────────────────────────▼──────────────────────────────┐ ║ ║ │ Bind 反射包装层(gmeta):权限校验 → 参数解析 → 分发 │ ║ ║ │ 路由元数据表 meta(权限/日志 注解即事实,补注册 goai) │ ║ ║ └──────────────────────────┬──────────────────────────────┘ ║ ║ controller(薄)→ service 接口(手维护)→ logic(ModuleSpec 基座) ║ ║ │ 侧路:utility/{jwt,captcha,cache,upload,excel,ip2region…} ║ ║ │ 侧路:gcron 调度器(job daemon + CleanTemp) ║ ║ ┌────▼─────────────────────────────────────────────────────┐ ║ ║ │ dao.Xxx.Ctx(ctx) —— 唯一查询入口 │ ║ ║ │ hooks.ApplyCtx:del_flag=0 + 租户级表 tenant_id IN(0,x) │ ║ ║ │ hooks.Handler:Insert 盖章 / Update 盖章 / Delete→软删 │ ║ ║ └────┬─────────────────────────────────────────────────────┘ ║ ╚══════╪══════════════════════════════════════════════════════════════╝ │ gdb(五驱动运行时切换) ┌──────▼────────┐ ┌──────────────┐ ┌─────────────────────────────┐ │ MySQL/PgSQL/… │ │ Redis(缓存) │ │ 磁盘 resource/upload + xdb │ └───────────────┘ └──────────────┘ └─────────────────────────────┘ 1.2 模块依赖方向图(允许 / 禁止) api/v1 ◀── controller ◀── logic ──▶ service(接口注册) │ ├──▶ dao ──▶ model/entity · model/hooks ──▶ gdb ├──▶ utility/*(无业务依赖) └──▶ consts internal/task ──▶ internal/logic ✅ 允许(任务复用 base 能力) internal/logic ──▶ internal/task ❌ 禁止(反向依赖) internal/middleware ──▶ 业务包 ❌ 禁止(经 wire.go 函数变量接线) internal/migrate ──▶ dao/logic ❌ 禁止(gdb 裸连,隔离钩子) base.go ──▶ 模块文件 ❌ 禁止(只依赖 dao/utility/consts) -------------------------------------------------------------------------------- 2. 启动编排详解(internal/cmd/cmd.go) -------------------------------------------------------------------------------- cache.Init(ctx) g.Redis() PING 失败 → memFallback=true,进程内 gcache 兜底; 运行期 Redis 出错同样自动切换(见 utility/cache)。 wireDependencies(ctx) IdentityLoader / TenantLoader / LogSink 任一为 nil 时输出警告日志, 不阻断启动(缺失的表现分别是:不加载角色权限、跳过租户校验、日志不落库)。 router.Register(s) 注册中间件、404/500 状态处理器(统一 JSON)、SetServerRoot("resource")、 /api/health、/swagger,随后逐个 Bind 全部控制器。 调度器:time.Sleep(2s) 后 DaemonInit(等 HTTP 监听就绪,任务可能是 HTTP 调自身)。 上传清理:gcron.AddSingleton("0 30 3 * * *") 每日 03:30 调 logic.CleanTemp, 保留天数取 file.tempExpireDays(默认 7),条目名 upload_temp_clean。 -------------------------------------------------------------------------------- 3. 路由注册与 gmeta 注解(internal/router) -------------------------------------------------------------------------------- 3.1 Bind 反射绑定(bind.go) Bind(s, new(controller.CXxx)) 扫描控制器全部导出方法,方法签名必须为: func (c *Xxx) Page(ctx context.Context, req *v1.XxxReq) (*v1.XxxRes, error) 对每个方法读取请求结构体内嵌 gmeta.Meta 的扩展标签: gmeta.Meta `method:"get" path:"/api/user/page" summary:"用户分页" permission:"sys:user:page" log:"用户管理-查询分页记录" logType:"query"` gmeta.Meta `method:"post" path:"/api/login" summary:"登录" public:"true"` 标签语义: method HTTP 方法(get/post/put/delete) path 完整路由模板(含 /api 前缀;:id 为路由参数) summary OpenAPI 摘要 permission 权限节点,形如 sys:{module小驼峰}:{action};空 = 登录即可 log 操作日志标题,形如 "{模块中文名}-{动作}";空 = 不记录 logType 日志类型字符串(query/add/update/delete/import/export/reset/…), 见 consts/log_type.go 的 LogTypeMap public "true" = 免登录(仍需与 middleware/public.go 白名单一致) 绑定层为每个路由做的事: 1) 写入路由元数据表 meta.Register(rm),键 "METHOD /path" 2) 补注册进 gf goai(闭包路由 gf 不会自动收集)→ /api.json 原生 OpenAPI 3) 生成统一包装 handler: - 路由元数据写入 ctx(操作日志中间件读取) - 权限校验:rm.Permission != "" 且 consts.HasPerm(ctx, permission) 为假 → 返回 code=403 信封(权限校验在包装层完成,不设独立 Permission 中间件) - 参数解析:r.Parse(req)(路由参数+query+body,含 gvalid 校验); 实现 BodyUnmarshaler 接口的请求对象走自定义解析逃生舱 - 调用业务方法,错误 → response.Fail;nil 出参 → Ok(nil);否则 Ok(出参) 3.2 路由元数据表(meta/meta.go) map["GET /api/user/page"] → RouteMeta{Method,Path,Summary,Permission,Log,LogType,Public} 读写锁保护;Lookup 供调试,All 供文档生成。权限校验与操作日志共用同一份元数据, 保证“注解即事实”。 3.3 路由注册表(router.go registerControllers) 按域顺序 Bind:auth → index → system7 → data7 → content3 → monitor7 → tool4(含 CExample 代码生成器注册示例)。 新增模块时在最后一个 Bind 行后追加(代码生成器会自动写入该行,见 07 章)。 404/500 状态处理器:非 200 一律输出统一 JSON 信封,不返回 HTML。 3.4 URL 约定(与 architecture.md 的差异,务必注意) * 路由 path 直接以 /api 开头,URL 中没有 /v1 版本段; api/v1 只是 Go 包名,不代表 URL 版本。例:GET /api/user/page。 * 公开路由白名单(middleware/public.go,精确匹配): /api/login、/api/register(白名单已放行,路由未注册)、/api/captcha、 /api/oauth2/token、/api/health 另放行 /api.json 与 /swagger*。 * 前端 baseURL 为 /api,模块路径与 gmeta path 逐条对齐。 -------------------------------------------------------------------------------- 4. 统一响应与错误码 -------------------------------------------------------------------------------- 4.1 响应信封(utility/response) 成功:{"code":0, "ok":true, "msg":"操作成功", "data":{...}} 失败:{"code":1, "ok":false, "msg":"...", "data":{}} 未认证:HTTP 401 + {"code":401,...}(前端据此跳登录页) Fail() 映射规则:gerror 码 == 401 原样保留;其余一律降级为 code=1 (前端 ResultEnum.ERROR=1 才弹 msg)。因此 consts 里的细分错误码 (1001 用户不存在、1003 密码错误……)主要用于日志与语义区分。 写出响应时把 code/body 记入 ctx(respCodeKey/respBodyKey), 供 OperationLog 中间件判断成功/失败与记录响应内容。 4.2 分页契约 分页接口 data 为 PageData[T](api/v1/common.go NewPageData): {"records":[...], "total":n, "size":pageSize, "current":pageNo, "pages":n} records 为空输出 [] 而非 null。 PageReq:pageNo 默认 1;pageSize 默认 10、上限 500(GetPageSize 截断)。 批量入参统一 IdsReq{ids:[...]};新增返回统一 IdRes{id:n}。 4.3 业务错误码(consts/biz_err.go,gcode.Register 语义值) 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 演示环境禁止操作 -------------------------------------------------------------------------------- 5. 数据访问与全局钩子(架构红线执行点) -------------------------------------------------------------------------------- 5.1 dao.Ctx 是唯一查询入口 dao/{模块}.go 由自定义模板(hack/tpl/dao_index.tpl)生成,做两件事: 1) 构造时 m.Hook(hooks.Handler()) —— 挂载 Insert/Update/Delete 全局钩子 2) Ctx 覆写 —— 进入 SQL 构建前注入条件: 所有表:del_flag = 0 租户级表且非平台上下文:tenant_id IN (0, 当前租户) 业务代码禁止绕过 dao 直连 g.DB(),禁止手写 del_flag/tenant_id 条件。 5.2 钩子行为(internal/model/hooks/hooks.go) Insert:主键交数据库自增(int,不使用雪花);补 del_flag=0、create_time、 create_user(取 ctx 用户名,回退真实姓名);租户级表自动携带 tenant_id (租户用户零值视为未指定→填自身;超管未指定→tenant.defaultId, 显式 0 可创建平台共享行)。 Update:自动盖章 update_time / update_user(仅 map 数据生效)。 Delete:物理删除一律改写为 UPDATE del_flag=1(软删)。 5.3 租户级表清单(tenantTables,共 9 张) goframe_user / role / dept / level / position / article / category / notice / link 平台级(超管,ctx tenant_id=0)查询不过滤,全量可见。 5.4 上下文键(consts/context_key.go) 写入方:JWT 中间件写 UserID/TenantID/Username/Realname/TokenJTI/TokenExpire/ Roles/Perms/DataScope/DeptID(后两者来自身份缓存,见 03 章数据权限节); Response 中间件写 ClientIP/UserAgent/ReqMethod/ReqURL。 读取方:全链路 consts.GetXxx(ctx)。 注意两套租户读法: CtxTenantID(ctx) 原始值(0=平台),仅供租户设施使用 GetTenantID(ctx) 业务归属值(租户返回自身,超管返回 tenant.defaultId), 新增/导入/唯一校验统一用它 数据范围读法: GetDataScope(ctx) 角色数据范围档位(1-全部;未注入视为全部) GetDeptID(ctx) 当前用户部门ID(0=未分配) 5.5 行级数据权限叠加位置(两层独立) 第一层(硬规则,不可配置):租户隔离 —— dao.Ctx/hooks.ApplyCtx 注入 tenant_id IN(0,自身),先于一切业务过滤,任何模块不可绕过。 第二层(可配置):角色数据范围 —— ModuleSpec.DataScope 挂点,在 applyFilters 链末环(Like→Eq→Keyword→Filter→DataScope)叠加; 共享工厂 ApplyDataScope(deptColumn)(logic/data_scope.go), 模块一行声明即生效,机制详见 03 章第 6 节。 -------------------------------------------------------------------------------- 6. 关键横切约定速查 -------------------------------------------------------------------------------- * 主键:int 数据库自增;新增后 result.LastInsertId() 回读。 * 软删:del_flag 0 正常 / 1 删除;删除接口全部软删。 * 表前缀:goframe_;文件命名 snake_case 对齐 dao(前端文件仍驼峰)。 * 入参/出参 json 驼峰;唯一例外:登录/令牌接口返回 access_token/refresh_token/expires_in/token_type(OAuth2 惯例,勿改)。 * 缓存键统一前缀 easygoadmin:(consts/cache_key.go)。 * 权限码 sys:{module小驼峰}:{action};超管角色编码 ROLE_SUPER_ADMIN, 前端通配权限 *:*:*。 * 每张表统一审计列:id/create_user/create_time/update_user/update_time/del_flag (租户表另有 tenant_id)。 * 命名规范补充:api 与 controller 同样使用 snake_case 文件名 (与早期“按域分目录”设计不同,以现状为准)。