269 lines
17 KiB
Plaintext
269 lines
17 KiB
Plaintext
================================================================================
|
||
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 文件名
|
||
(与早期“按域分目录”设计不同,以现状为准)。
|