签入版本

This commit is contained in:
小蚂蚁云
2026-10-20 21:53:26 +08:00
parent 5cc3da657c
commit b33799aa63
427 changed files with 32547 additions and 1 deletions
@@ -0,0 +1,98 @@
# 4.3 后端分层架构
:::tip 概述
后端五层:api / controller / service / logic / dao。分层不是形式主义——每层有明确的"允许/禁止"清单,组合起来构成依赖红线体系。本节逐层说明职责与边界。
:::
## 分层与调用关系
```text
api/v1(契约层)
▲ 依赖
controller(HTTP 薄层)
▲ 依赖接口
service(接口层,手维护)
▲ 实现
logic(业务层,ModuleSpec 基座 + 钩子 + 手写逃生舱)
▲ 唯一入口
dao(数据访问层,红线条件 + 全局钩子)
▲
model/entity · model/hooks · gdb
```
侧路能力(横向复用):`utility/{cache,captcha,crypto,excel,ip2layer,jwt,response,ua,upload}` 与 `consts`(错误码/缓存键/上下文键/日志类型)。
## 各层职责与边界
### api/v1(契约层)
+ 全部 Req/Res 结构体,**单包 v1**,每模块一文件(snake_case)
+ gmeta 注解声明 method/path/summary/permission/log/logType/public
+ gvalid 标签声明校验规则;`dc` 标签供 OpenAPI 文档
+ **禁止**:业务逻辑、数据库引用
### controller(HTTP 薄层)
+ 一行透传 `return service.Xxx().Method(ctx, req)`
+ **禁止**:业务 if/for、import dao、手写响应信封、手写参数解析
### service(接口层,手维护)
+ 每模块一文件:接口 `IXxx` + 实例变量 + 取用函数 `Xxx()` + 注册函数 `RegisterXxx()`
+ 未注册时取用 panic(启动即暴露接线错误)
+ **禁止**:整体运行 `gf gen service`(会覆盖手工重组结构)
### logic(业务层)
+ 每模块一文件:`sXxx` 实现 + `xxxSpec` 声明 + `init()` 注册 + 标准委托 + 扩展手写
+ 共享能力经 `base.go`(15 个导出上限)与 `module_spec.go` 基座
+ 跨模块复用走同包包级函数(ResolveCityNames / LoadIdentity / ApplyDataScope / DictGetText…)
+ **禁止**:反向 import task;绕过 dao;在钩子里做 HTTP/文件 IO
### dao(数据访问层)
+ 自定义模板产出:构造时挂 `hooks.Handler()`;`Ctx` 覆写注入红线条件
+ **禁止**:绕过直连 `g.DB()`;手写 del_flag/tenant_id 条件;手改 dao/internal/
## 上下文键(consts/context_key.go)
JWT 中间件写入身份,全链路 `consts.GetXxx(ctx)` 读取:
| 写入方 | 键 |
|--------|-----|
| JWT 中间件 | UserID / TenantID / Username / Realname / TokenJTI / TokenExpire / Roles / Perms / DataScope / DeptID |
| Response 中间件 | ClientIP / UserAgent / ReqMethod / ReqURL |
::: warning 两套租户读法(易错点)
`CtxTenantID(ctx)` —— 原始值(0=平台),仅供租户设施(hooks/中间件)使用
`GetTenantID(ctx)` —— 业务归属值(租户返回自身,超管返回 tenant.defaultId),**新增/导入/唯一校验统一用它**
:::
数据范围读法:`GetDataScope(ctx)` 档位(1-全部;未注入回退全部);`GetDeptID(ctx)` 部门 ID(0=未分配)。
## 响应与错误码
统一信封(utility/response):
```text
成功:{"code":0, "ok":true, "msg":"操作成功", "data":{...}}
失败:{"code":1, "ok":false, "msg":"...", "data":{}}
未认证:HTTP 401 + {"code":401,...}
```
Fail 映射规则:gerror 码 == 401 原样保留;其余一律降级 code=1(前端 ResultEnum.ERROR 才弹 msg)。consts 中的细分错误码(1001 用户不存在…)主要用于日志与语义区分。
分页契约:`PageData[T]` = `{records, total, size, current, pages}`,records 为空输出 `[]` 而非 null;pageNo 默认 1,pageSize 默认 10 上限 500。
## 横切约定速查
```text
* 主键:int 数据库自增;新增后 result.LastInsertId() 回读
* 软删:del_flag 0 正常 / 1 删除;删除接口全部软删
* 表前缀:goframe_;文件命名 snake_case 对齐 dao(前端文件仍驼峰)
* 入参/出参 json 驼峰;唯一例外:登录/令牌接口返回
access_token/refresh_token/expires_in/token_type(OAuth2 惯例,勿改)
* 缓存键统一前缀 easygoadmin:
* 权限码 sys:{module小驼峰}:{action};超管 ROLE_SUPER_ADMIN;前端通配 *:*:*
* 审计列:id/create_user/create_time/update_user/update_time/del_flag(租户表另有 tenant_id)
```
@@ -0,0 +1,111 @@
# 4.6 核心基座体系
:::tip 概述
本项目没有传统"基类继承"——用**组合**替代继承:ModuleSpec 泛型基座(module_spec.go)承担 CRUD 流水线,base.go 共享能力层提供横切函数。本节解释两者的分工与设计纪律。
:::
## 组合替代继承的架构
```text
┌────────────────────────────────────────────────────┐
│ 模块 logic 文件(example.go) │
│ var exampleSpec = ModuleSpec[...]{ 声明差异 } │──▶ 委托
│ func 方法() { exampleSpec.Page(...) } │
│ func 扩展() { 手写(合法逃生舱)} │
└───────────────────────┬────────────────────────────┘
│ 复用
┌───────────────────────▼────────────────────────────┐
│ module_spec.go:ModuleSpec 泛型基座 │
│ applyFilters / readTransform / saveTransform │
│ checkUniqueRules / FillTextFields / buildTree │
└───────────────────────┬────────────────────────────┘
│ 调用
┌───────────────────────▼────────────────────────────┐
│ base.go 共享能力层(导出上限 15 个) │
│ 文件转正 / 富文本 / 字典文本 / 唯一校验 / │
│ 事务 / 分块查询 / 泛型树 / 临时目录清理 │
└────────────────────────────────────────────────────┘
```
## base.go 能力清单(internal/logic/base.go)
| 能力 | 函数 | 一句话职责 |
|------|------|-----------|
| 文件字段 | SaveFileFields / SaveMultiFileFields | 批量转正(原地更新 map) |
| | SaveFileField / SaveMultiFileField | 单值便捷版 |
| 富文本 | LocalizeContentFields | 存库本地化([IMG_URL] 占位符化) |
| | ExpandContentFields | 读取还原(**唯一还原出口**) |
| 枚举文本 | DictGetText | 字典值 → 显示名(进程内缓存 300s) |
| | SerializeMapOnRead | map 行补 {field}Text |
| | LogTypeText | 操作日志类型 → 中文(静态表) |
| 唯一校验 | CheckUnique | UX 快速报错(TOCTOU,真值靠 uk_* 索引) |
| 事务 | WithTrans | 事务包裹(错误自动回滚) |
| 分块 | ChunkQuery | 分块遍历(导出防 OOM,默认 500/页) |
| 树 | BuildTree[T] | 泛型平铺转树 |
| 清理 | CleanTemp | 清理上传临时日期目录 |
架构纪律(文件头约束,真实注释):
```text
1) 按能力分区:文件字段 / 富文本 / 枚举文本 / 唯一校验 / 事务 / 分块查询 / 树
2) 依赖方向单向:只允许依赖 dao / utility / consts,禁止引用模块文件
3) 导出函数上限 15 个,超出按能力拆 internal/logic/base/ 子包
4) CheckUnique 仅为 UX 快速报错(TOCTOU),并发唯一性靠 uk_* 索引
5) [IMG_URL] 占位符是持久化契约,还原只能走 ExpandContentFields
```
## ModuleSpec 基座流水线(module_spec.go)
```text
Page applyFilters → Count → applyOrder.Page → structRows → readTransform
→ AfterPageList
List applyFilters → 排序 → req.limit 上限 → structRows → readTransform
→ IsTree 时 buildTree(叶子 Children 预置 [] 而非 null)
Detail Model.Where(id).One → Struct → readTransform → AfterDetail
Add ToData → BeforeAdd → saveTransform(文件/富文本转正)→ checkUniqueRules
→ Insert → LastInsertId → AfterAdd → 返回 id
Update ToData → BeforeUpdate → saveTransform → checkUniqueRules(排除自身)
→ Update(仅更新 data 中出现的键)→ AfterUpdate
Delete BeforeDelete → Delete(钩子改写软删)→ AfterDelete
BatchDelete BeforeBatchDelete → 逐条 BeforeDelete+软删 → AfterBatchDelete
```
readTransform 顺序:FileFields 补 URL → MultiFileFields 补 URL → ContentFields 展开 [IMG_URL] → FillTextFields({field}Text)。
## 设计纪律(为什么基座不会腐化)
::: warning 基座三铁律
1. **基座禁止"按模块分支"**——差异只进声明字段与钩子;写不进钩子 → 手写(合法逃生舱)。这条纪律保证基座永远不出现 `if module == "user"` 式代码。
2. **钩子用具体函数类型**,req any 经 mustAs[T] 断言(失败=编程错误,panic 暴露而非带病运行)。
3. **钩子内禁止 append、禁止 HTTP/文件 IO**——文件迁移统一在 saveTransform 完成。
:::
applyFilters 固定顺序:`LikeFields → EqFields → KeywordFields → Filter → DataScope`;租户/软删隔离由 dao.Ctx **先于本链**注入(任何模块不可绕过)。
## ModuleSpec 声明字段全景
```text
Model func(ctx) *gdb.Model 数据源(通常 dao.Xxx.Ctx)
LikeFields []string 逐列 LIKE(值取 req 同名驼峰字段,空跳过)
EqFields []string 等值(零值跳过)
KeywordFields []string req.keyword 多列 OR(基座保留能力)
Filter func(ctx, m, req) *gdb.Model 复杂条件逃生舱
OrderBy / ListOrderBy / OrderableFields 排序(白名单控制前端排序)
IsTree / ParentOf / ChildSetter 树形
SerializeMaps map[字段]字典编码 {field}Text 填充
FileFields / MultiFileFields / ContentFields / FileSaveDir 文件与富文本
UniqueFields []UniqueRule{Field, Group} 唯一性预检(Group 分组唯一)
DataScope DataScopeFunc 行级数据权限(ApplyDataScope 接线)
ToData req → map[string]any 新增/修改共用转换
钩子 Before/After × Add/Update/Delete/Batch + AfterPageList/AfterDetail
```
## 与"基类方案"的对比
| 维度 | 本项目(泛型基座+组合) | 传统基类继承 |
|------|----------------------|-------------|
| 差异表达 | 声明字段 + 钩子 | 重写虚方法 |
| 类型安全 | 泛型三类型参数编译期约束 | 常退化 any/map |
| 复用粒度 | 函数级(base.go 自由组合) | 类级(受继承链束缚) |
| 基座污染风险 | 三铁律禁止按模块分支 | 基类易堆积 if-else |
| 逃生舱 | 手写方法(完全自由) | 多重继承/桥接方法 |
@@ -0,0 +1,94 @@
# 4.8 缓存架构设计
:::tip 概述
缓存实现为 utility/cache 统一门面:Redis 优先,连接失败或运行期出错自动降级进程内存 gcache(UsingMemory() 可查)。三层缓存各司其职,Redis 键统一前缀 easygoadmin:。
:::
## 缓存分层图
```text
请求 ──▶ ① 进程内缓存(sync.Map,无 Redis 交互,重启即清)
dictTextCache(300s) · reqFieldCache/textPlanCache(反射计划,永不过期)
maskReCache(正则,永不过期)
──▶ ② Redis(跨实例共享,TTL 自动回收)
黑名单/在线/验证码/字典/租户摘要/身份/限流/任务锁
──▶ ③ 数据库(唯一真相,dao.Ctx 全量过滤条件)
设计原则:② ③ 之间的一切缓存都必须有明确的失效点或短 TTL 兜底;
① 仅缓存"计算计划/只读文本",不允许缓存业务状态。
```
## Redis 键全集
| 键模式 | TTL | 写入方 | 读取方 |
|--------|-----|--------|--------|
| `easygoadmin:token:blacklist:{jti}` | 剩余有效期+60s | Logout/Refresh 轮换/RemoveToken/ForceLogout | JWT 中间件 Exists 判定 |
| `easygoadmin:online:jti:set`(zset) | 成员随 info(score=过期 ts) | MarkOnline/MarkOffline | OnlineUser.Page(ZRangeByScore≥now) |
| `easygoadmin:online:info:{jti}` | access 剩余+60s | MarkOnline/MarkOffline | OnlineUser.Page/Detail/ForceLogout |
| `easygoadmin:captcha:{uuid}` | 300s | Captcha 生成 | Login/Verify(一次性) |
| `easygoadmin:dict:item:{code}` | 3600s | dict.setCache/RefreshCache | dict_item 快捷接口/AliasList |
| `easygoadmin:tenant:info:{id}` | 60s | Tenant 中间件/Login 路径 | Tenant 中间件/Login |
| `easygoadmin:user:identity:{uid}` | 300s | JWT 中间件(回源 Loader) | JWT 中间件(Roles/Perms/DataScope/DeptId JSON) |
| `easygoadmin:user:perms:{uid}` / `user:roles:{uid}` | 约定键(保留扩展) | — | 主路径用 identity 合并键,变更时一并清理 |
| `ratelimit:{ip}:{path}:{窗口起点}` | window+5s | RateLimit incr | RateLimit |
| `job:lock:{jobId}` | 300s 兜底 | jobTryLock(NX) | jobTryLock(执行完 defer Del) |
## 主动失效点清单
**身份缓存 `user:identity:{uid}` 删除时机**:
```text
* role.SavePermission 保存角色权限 → clearRoleIdentityCache(roleId)
→ 查该角色全部 user → 逐个 Del
* role.Update 修改角色(含 data_scope 变更)→ AfterUpdate → 同上
* user.AssignRoles 分配/调整角色 → clearIdentityCache(userId)
(user.Update / user.Add 均经 AssignRoles——改部门 dept_id 亦在此清缓存)
* 版本兼容:Identity 新增字段反序列化缺省为 0,读侧 GetDataScope 回退"全部"
——升级无需强清存量缓存
```
**其他**:租户摘要在 tenant 状态/过期变更时由 AfterUpdate clearCache 钩子清理;字典 Redis 缓存由 RefreshCache 主动重建(dict_item 变更不主动清,最长 1 小时延迟);黑名单靠 TTL 自然过期(过期即令牌本身已失效);验证码命中即删。
## 进程内缓存明细
| 缓存 | 位置 | 内容 | TTL |
|------|------|------|-----|
| dictTextCache | base.go | {code}:{value} → 显示名 | 300s |
| reqFieldCache / textPlanCache / childrenIdxCache | module_spec.go | 反射索引与填充计划 | 永不过期(类型不变即安全) |
| maskReCache | middleware/util.go | 脱敏正则 | 永不过期 |
## 内存降级的影响面
Redis 不可用时 `UsingMemory()=true`,以下能力**退化单机语义**:
```text
限流计数 各实例各自计数(阈值实际翻倍)
任务锁 job:lock 退化单机,多实例可能并发执行任务
在线用户 只看本实例登录的会话
JWT 黑名单 本实例签发的登出/强退不拦截其他实例
验证码 单实例内有效
字典缓存 各实例独立回源
```
运维可通过 `GET /api/cache/info` 的 `mode` 字段确认当前模式(redis/memory)。
## TTL 设计原则(新增缓存遵循)
```text
1) 状态判定类(黑名单/租户状态):TTL ≤ 业务有效期,且必须有主动失效点;
宁可短 TTL 多回源,不可长 TTL 陈旧放行
2) 展示文本类(字典/归属地):TTL 300s~3600s + 刷新接口,接受分钟级延迟
3) 高频读聚合类(identity):合并为单键 JSON(一次回源装多个集合),
TTL 300s,变更路径主动 Del
4) 计数/锁类:键内嵌窗口起点或场景唯一 id,TTL 只做回收不参与语义
5) 任何经 cache.Set 的值必须可 JSON 序列化;读侧 Unmarshal 失败视为未命中回源
```
## 排障速查
```text
键不存在/值陈旧 先查是否内存降级(redis-cli KEYS 'easygoadmin:*' 对比)
权限不生效 DEL easygoadmin:user:identity:{uid}
租户被禁仍可访问 等 60s 或 DEL easygoadmin:tenant:info:{id}
多实例行为不一致 确认 Redis 可用(/api/cache/info mode 应为 redis)
```
@@ -0,0 +1,116 @@
# 4.7 横切机制
:::tip 概述
审计字段盖章、软删除改写、租户隔离三类横切逻辑不在任何业务代码中出现——它们统一在 gdb 模型钩子层(internal/model/hooks)执行。配合中间件链与 Bind 包装层,构成本项目的横切机制全景。
:::
## 横切机制全景
```text
请求横切(中间件链) 数据横切(模型钩子) 契约横切(Bind 包装层)
CORS Insert 盖章 权限校验(gmeta permission)
Response(panic/元信息) Update 盖章 参数解析(gvalid)
RateLimit Delete → 软删 信封写出
Demo del_flag=0 注入 OpenAPI 补注册
JWT(身份) tenant_id 注入
Tenant(状态)
OperationLog(采集)
```
## 全局钩子(internal/model/hooks/hooks.go)
钩子经 `dao.{X}.Ctx(ctx)` 构造模型时挂载(自定义模板 dao_index.tpl):
| 钩子 | 触发 | 行为 |
|------|------|------|
| Insert | 任意 Insert | 主键交数据库自增(不填 id);补 del_flag=0、create_time、create_user(ctx 用户名,回退真实姓名);租户级表自动携带 tenant_id |
| Update | 任意 Update | 自动盖章 update_time / update_user(仅 map 数据生效) |
| Delete | 任意 Delete | 物理删除一律改写为 `UPDATE del_flag=1` |
### 租户盖章规则(Insert)
```text
租户用户插入未显式指定 tenant_id → 填自身(防逃逸)
超管未显式指定 → 填 tenant.defaultId(默认 1)
显式指定具体值 → 从显式值
显式 0 → 建平台共享行
```
### 查询条件注入(hooks.ApplyCtx)
```text
所有表: WHERE del_flag = 0
租户级表且非平台上下文:WHERE tenant_id IN (0, 当前租户)
超管(ctx tenant_id=0):不过滤,全量可见
```
## 租户级表清单(tenantTables,9 张)
```text
goframe_user / role / dept / level / position / article / category / notice / link
```
**新增租户级表必须同步修改 hooks.go 的 tenantTables**(代码生成器不自动维护此清单)。漏加的后果是租户数据互相可见。
## 两层行级过滤的叠加关系
```text
第一层(硬规则,不可配置):租户隔离
dao.Ctx / hooks.ApplyCtx 注入 tenant_id IN(0,自身)
先于一切业务过滤,任何模块不可绕过
第二层(可配置):角色数据范围(data_scope 四档)
ModuleSpec.DataScope 挂点,在 applyFilters 链末环叠加:
Like → Eq → Keyword → Filter → DataScope
共享工厂 ApplyDataScope(deptColumn),模块一行声明即生效
```
详见 [5.8 多租户隔离](../core/tenant) 与 [5.9 数据权限](../core/data-scope)。
## 请求横切:中间件与包装层分工
```text
中间件链(跨路由共性) Bind 包装层(路由级个性)
认证状态(401) 权限码校验(403,读 gmeta)
租户状态(1005/1006) 参数解析与校验(gvalid)
限流(429)/演示(1015) 信封写出
日志采集(不短路) OpenAPI 元数据
```
权限校验不设独立中间件——因为它需要每路由不同的权限码,放在读路由元数据的包装层最自然(与日志注解同源,"注解即事实")。
## 日志横切(OperationLog)
```text
gmeta log 标签 → 路由元数据 → 中间件组装 entry(脱敏 param/result)
→ 有界通道 2048(满则丢弃告警,不阻塞业务)
→ 单 worker 攒批(100 条或 1s flush)
→ LogSink(operation_log 模块 init 注册)→ INSERT goframe_operation_log
```
脱敏规则:param 中 password/oldPassword/newPassword/salt/access_token/refresh_token → `***`;result 截断 8000 字节(UTF-8 边界回退)。
## 配置横切:环境变量覆盖(utility/config)
```text
启动入口 cmd.Run 最先执行 config.RegisterEnvOverride()
→ 包装默认文件适配器为 env-first 适配器(gcfg.Adapter 官方扩展点)
→ 全部 Get/MustGet 读取点零改动获得覆盖能力(含 gdb/gredis 经整节点读连接配置)
```
```text
优先级 GF_ 环境变量 > 配置文件(12-Factor;未设环境变量时行为不变)
键名 配置路径大写下划线 + GF_ 前缀(database.default.link → GF_DATABASE_DEFAULT_LINK)
空值 环境变量空串视为未设置,不清空文件有效值
范围 仅覆盖配置文件中已存在的键(新增键先在 config.yaml 声明)
```
典型场景:容器部署注入 `GF_DATABASE_DEFAULT_LINK` / `GF_FILE_UPLOADDIR` / `GF_JWT_SECRET`,镜像内 config.yaml 保持默认不动(同一镜像可部署多环境)。键名规则与 Docker 注入方式见 [2.5 配置文件说明](../quickstart/env-vars)。
## 横切开发纪律
+ 新增横切逻辑优先问:属于请求横切(中间件)/ 数据横切(钩子)/ 契约横切(Bind)/ 配置横切(utility/config)哪一层?不要散落在业务代码。
+ 修改钩子必须全表回归——它影响 25 张表的所有写入。
+ 钩子内禁止业务分支判断(如"某模块跳过盖章")——确有需要走逃生舱(显式值优先于自动盖章)。
+ 缓存类横切(字典/身份/租户摘要)必须有明确失效点或短 TTL 兜底(见 [4.8 缓存架构设计](./cache))。
+ 业务代码读配置一律用 `g.Cfg().Get/MustGet`,不要用 `GetWithEnv`(文件优先,环境变量永不生效)。
@@ -0,0 +1,131 @@
# 4.9 数据库设计
:::tip 概述
25 张业务表、统一审计列、13 个唯一索引、9 张租户级表、注释枚举驱动字典——本节给出数据模型全景与 ER 关系。五方言定稿脚本位于 document/{mysql,postgresql,sqlserver,oracle,sqlite}/,MySQL 版为规范基准。
:::
## ER 关系图(逻辑外键,无物理外键约束)
```text
goframe_tenant ──< goframe_user >──┬──< goframe_user_role >── goframe_role
│ │
│ goframe_role_menu
│ │
├──< goframe_dept ▼
├──< goframe_level goframe_menu
├──< goframe_position ▲
│ (user.level_id/position_id/dept_id 引用)
│
goframe_config ──< goframe_config_item
goframe_dict ──< goframe_dict_item
goframe_category(树 parent_id) ──< goframe_article(tenant_id, category_id)
article: cover 单图 / images 多图 / content 富文本
goframe_notice / goframe_link(tenant_id)
goframe_city(树 pid + parent_code,全国行政区划,无租户)
goframe_job ──< goframe_job_log(job_id, job_name 冗余)
goframe_operation_log / goframe_login_log(独立日志表,无租户)
goframe_file_template(全局文件模板,code 唯一)
goframe_param(全局系统参数,code 唯一)
goframe_example(案例演示表,导入导出/状态演示用)
">" 侧为多端;如 user >-- user_role --< role 为多对多。
```
## 全表统一规范
```text
表前缀 goframe_;存储引擎 InnoDB;字符集 utf8mb4。
每张表统一审计列(25 表齐备):
id int NOT NULL AUTO_INCREMENT 主键(数据库自增,禁用雪花)
create_user varchar(50) 创建人(ctx 用户名)
create_time datetime DEFAULT (now()) 创建时间
update_user varchar(50) 更新人
update_time datetime DEFAULT (now()) 更新时间
del_flag int DEFAULT 0 0-正常 1-已删除(软删)
租户级表另有 tenant_id int DEFAULT 0(0=平台级),共 9 张。
删除一律软删(deleteHook 改写);查询一律经 dao.Ctx 过滤 del_flag=0。
```
## 25 张表清单
| 表名 | 中文注释 | 域 | 租户级 | 特征列/说明 |
|------|---------|-----|--------|------------|
| goframe_article | 文章表 | content | 是 | category_id/cover/images(多图)/content(富文本)/status/click/sort |
| goframe_category | 文章分类表 | content | 是 | 树形 parent_id、category_name/code/sort |
| goframe_city | 城市行政区划表 | data | 否 | 树形 pid+parent_code、city_code/area_code/level、lng/lat/pinyin(大数据量表) |
| goframe_config | 系统配置表 | data | 否 | name/code/sort |
| goframe_config_item | 配置项表 | data | 否 | config_id/value/options/type/status/sort |
| goframe_dept | 部门表 | system | 是 | 树形、删除有子级/用户守卫 |
| goframe_dict | 数据字典表 | data | 否 | name/code;缓存 dict:item:{code} |
| goframe_dict_item | 字典项表 | data | 否 | dict_id/name/value/sort |
| goframe_example | 案例演示表 | tool | 否 | 七件套+导入导出+状态演示 |
| goframe_file_template | 文件模板表 | tool | 否 | code 唯一;getTemplateByCode |
| goframe_job | 定时任务表 | monitor | 否 | job_name/cron_expression/execute_policy/is_sync/url/params/status(0未发布/1运行/2暂停) |
| goframe_job_log | 定时任务日志表 | monitor | 否 | job_id/status/耗时/exception_info |
| goframe_level | 职级表 | system | 是 | 导入导出启用;user_count 展示 |
| goframe_link | 友链表 | content | 是 | 无 status 列 |
| goframe_login_log | 登录日志表 | monitor | 否 | log_type(1登录/2退出)/ip/location/os/browser/status |
| goframe_menu | 菜单表 | system | 否 | 树形;type(0菜单/1权限节点)/permission/status/hide/sort |
| goframe_notice | 通知公告表 | data | 是 | status |
| goframe_operation_log | 操作日志表 | monitor | 否 | title/type(log_type枚举)/param/result/ip/location/consume_time/status/error |
| goframe_param | 系统参数表 | data | 否 | code 唯一 |
| goframe_position | 岗位表 | system | 是 | |
| goframe_role | 角色表 | system | 是 | code/data_scope(四档行级数据权限) |
| goframe_role_menu | 角色菜单关联表 | system | 否 | role_id+menu_id(无模块文件,归 role logic) |
| goframe_tenant | 租户表 | system | 否 | code(T+ID)/status/expire_time/max_user_num |
| goframe_user | 用户表 | system | 是 | username/password(bcrypt)/salt/mobile/email/avatar/dept_id/level_id/position_id/status/code |
| goframe_user_role | 用户角色关联表 | system | 否 | user_id+role_id(无模块文件,归 user logic) |
## 唯一索引清单(13 个)
| 表 | 索引名 | 列 | 语义 |
|----|--------|-----|------|
| goframe_category | uk_tenant_name | (tenant_id, category_name, del_flag) | 租户内分类名唯一 |
| goframe_config | uk_code | (code) | 全局配置编码唯一 |
| goframe_dept | uk_tenant_name | (tenant_id, name, del_flag) | 租户内部门名唯一 |
| goframe_dict | uk_code | (code) | 全局字典编码唯一 |
| goframe_file_template | uk_code | (code) | 全局模板编码唯一 |
| goframe_level | uk_tenant_name | (tenant_id, name, del_flag) | 租户内职级名唯一 |
| goframe_param | uk_code | (code) | 全局参数编码唯一 |
| goframe_position | uk_tenant_name | (tenant_id, name, del_flag) | 租户内岗位名唯一 |
| goframe_role | uk_tenant_code | (tenant_id, code, del_flag) | 租户内角色编码唯一 |
| goframe_tenant | uk_code | (code) | 租户编码全局唯一 |
| goframe_user | uk_tenant_username | (tenant_id, username, del_flag) | 租户内用户名唯一 |
| goframe_user | uk_tenant_mobile | (tenant_id, mobile, del_flag) | 租户内手机号唯一 |
| goframe_user | uk_tenant_code | (tenant_id, code, del_flag) | 租户内用户编码唯一 |
要点:索引含 del_flag(软删行不占坑,与 dao.Ctx 恒过滤语义一致);含 tenant_id 的索引,模块唯一预检范围必须等于索引范围。
## 枚举列约定(列注释驱动字典与生成器)
```text
status(账号/租户/字典项等) 1-正常 2-禁用
article.status 0-正常 1-下架
city.level 0-省份 1-城市 2-县区 3-街道 4-社区
menu.type 0-菜单 1-权限节点
job.status 0-未发布 1-运行中 2-暂停
job.execute_policy 1-立即执行 2-执行一次 3-放弃执行
job.is_sync 1-同步 0-异步
role.data_scope 1-全部 2-本部门及以下 3-本部门 4-仅本人
del_flag 0-正常 1-已删除
login_log.log_type 1-登录 2-退出
operation_log.type 见 consts/log_type.go(41 个值,静态映射不走字典)
```
## 大表与性能注意
+ **goframe_city** 全国行政区划大表(AUTO_INCREMENT 已至 78 万+):查询走 parent_code/pid 索引路径(级联懒加载 getCityList),避免全表 List;导入/迁移注意批大小。
+ **日志三表**(operation_log/login_log/job_log)只增不改,依赖 LogClean 任务按 `job.logRetentionDays`(默认 90 天)分批物理清理。
+ **导出接口**统一 ChunkQuery 分批(500/页)防 OOM。
## 建表/变更工作流
```text
1) 以 document/mysql/ 定稿为基准补 ALTER(含列注释枚举、uk_* 唯一索引、uk_/idx_ 命名规范)
2) gf gen dao -c hack/config.yaml 刷新 dao/entity/do(service 手维护)
3) 同步 document/ 其余四方言脚本(人工校对)
4) 租户级表 → 修改 hooks.tenantTables
5) go build ./... 重启;必要时 go run ./cmd/migrator 同步到其他环境库
```
@@ -0,0 +1,83 @@
# 4.5 目录结构设计
:::tip 概述
目录结构是架构决策的物理体现。本节解释"为什么这样放"——平铺单包、snake_case 命名、internal 边界、utility 无业务依赖等约定背后的原因。完整目录树见 [1.3 项目目录结构](../directory)。
:::
## 后端顶层布局与设计意图
```text
main.go 入口极薄:注册五库驱动 + Redis 适配器 → cmd.Run
internal/ 私有包边界(Go internal 语义:外部不可 import)
cmd/ 启动编排独立成包——把 main 的"做事"与"声明"分离
api/v1/ 契约层放 internal 外?——不,本项目放顶层 api/,
便于生成器与 gf 工具链定位,也表达"对外契约"语义
controller/ logic/ service/ dao/ 四层各自单包平铺
middleware/ 中间件 + wire.go 函数变量接线(不 import 业务包)
migrate/ 迁移引擎独立——gdb 裸连,必须与 dao 体系隔离
model/hooks/ 全局钩子独立于 entity/do(生成物与手写物分离)
router/ router.go(注册)+ bind.go(反射)+ meta/(元数据表)
task/ 定时任务实现——单向依赖 logic,cmd 空白导入触发
templates/ 生成器模板独立于 internal(模板演进与代码解耦)
utility/ 基础能力包——无业务依赖,可供任何层使用
manifest/ 配置与数据(ip2region.xdb)
resource/ 静态资源(SetServerRoot 提供 /upload/**、swagger 页面)
hack/ 工具链配置(gf gen dao 模板、golden 回归基线)
```
## 平铺 vs 分目录的取舍
| 维度 | 单包平铺(选择) | 按域分目录(弃用) |
|------|----------------|------------------|
| 跨模块复用 | 同包直接调用包级函数,零 import、无循环依赖风险 | 需 import,深组合易循环 |
| 新增模块 | 4 个文件各就各位,无目录推断 | 多级目录间跳转 |
| 生成器 | 目标路径唯一确定 | 需推断层级 |
| init 注册 | 同包全量加载,无需 blank import | 每包需 blank import |
| 代价 | 单包文件多(30+) | 包粒度细 |
Go 编译器对大包无压力,而"跨模块直接调用"的收益在中后台系统里极高(city 编解码、身份加载、字典文本被大量模块复用)。
## 文件命名规范
```text
后端(对齐 dao 产物):表名去 goframe_ 前缀的 snake_case
api/v1/config_item.go · internal/controller/config_item.go
internal/logic/config_item.go · internal/service/config_item.go
前端:驼峰
src/api/data/config.ts · src/views/data/configItem/
原因:后端文件名与 dao/entity/do 生成物一致,生成器与人工开发心智统一;
前端沿用 JS 社区驼峰惯例,两边各自从俗。
```
## internal 的边界价值
`internal/` 是 Go 编译器强制的私有边界——仓库外代码无法 import,防止外部项目把实现细节当公共 API 依赖。分层红线(controller 不 import dao 等)靠 code review + 生成器模板保证,internal 防的是"仓库外扩散"。
## utility 的纪律
```text
utility/* 只依赖标准库与三方库,禁止 import internal(防反向耦合)
cache 统一缓存门面(Redis 优先、内存降级)
captcha 验证码生成/校验
crypto bcrypt + salt
excel excelize 封装(ReadSheet/WriteSheet)
ip2region xdb 归属地(懒加载 + 读锁)
jwt HS256 双令牌 Sign/Parse
response 统一信封写出
ua OS/Browser 解析
upload 两段式上传(SaveTemp/SaveFromTemp/GetFileUrl)
```
## 前端目录设计
```text
views 按"域"分目录(system/data/content/monitor/tool…)与后端模块域对应;
api 同样按域分目录;
components/store/utils/hooks 不按域——全局共享层;
settings/styles/enums 全局配置面。
```
views 域归属与生成器 genDomainMap 一致(user/role→system,config→data,article→content,operation_log→monitor,example→tool,默认 tool),生成器据此决定前端产出路径。
@@ -0,0 +1,117 @@
# 4.11 扩展性设计
:::tip 概述
扩展性来自两方面:一是**标准变更的操作清单**(按变更类型给出 checklist,不漏步骤、不变异架构);二是**逃生舱挂点**(基座装不下的需求有明确的合法出口)。本节按变更类型组织。
:::
## 变更类型 Checklist
### 1. 给现有模块新增一个接口
```text
□ api/v1/{模块}.go:新增 {Mod}{Action}Req(内嵌 gmeta.Meta,path 带 /api 前缀、
permission、log、logType)+ {Mod}{Action}Res
□ controller:同名方法,签名 func (c *CXxx) Action(ctx, req) (res, err)
□ service/{模块}.go:接口追加方法声明(手维护)
□ logic:实现方法(基座可表达走 spec 钩子,复杂走手写)
□ 权限节点同步:若 permission 为新码,需在菜单管理为角色勾选
(或插入 goframe_menu type=1 节点)
□ go build ./... && gofmt;重启;/api.json 核对新条目
```
### 2. 给现有模块加字段
```text
□ MySQL ALTER(列注释按枚举规范写"字段:1-x 2-y";需要唯一性则补 uk_*)
□ gf gen dao -c hack/config.yaml(刷新 entity/do/dao internal;service 不动)
□ api/v1:Req/Res/Item 结构体补字段(json 驼峰 + dc 注释)
□ logic:ToData 补键;枚举补 SerializeMaps(字典编码 {模块}_{字段});
文件字段补 FileFields+FileSaveDir;富文本补 ContentFields
□ 前端:columns.ts / querySchemas.ts / edit.vue 同步
□ document/ 五方言脚本同步;必要时附录表清单更新
□ go build → 重启 → page/detail 黄金样本更新
```
### 3. 新增模块
```text
□ 设计表(统一审计列 + del_flag + uk_* + 注释枚举)→ DDL 落库
□ 租户级?→ hooks.tenantTables 登记
□ go run ./cmd/generator -tables "goframe_x|中文名"(或手动四件套)
□ logic 调整:spec 声明(筛选/树形/字典/文件/唯一 Group)、扩展接口
□ service 接口核对;router.go 注册行确认
□ go build ./... → 重启 → 角色勾选新权限节点
```
### 4. 新增中间件
```text
□ internal/middleware/{name}.go:func(r *ghttp.Request),职责单一;
读配置用 cfgBool/cfgInt;客户端 IP 用 realClientIP;响应用 utility/response
□ 不得 import 业务包——需要业务数据时在 wire.go 增加 Loader 函数变量,
由 logic init 注册(参照 IdentityLoader 模式)
□ router.Register 的 s.Use 链按序插入(顺序即语义,评审明确插入点)
□ public 路径行为:确认对 IsPublicPath 的放行策略
```
### 5. 新增定时任务
```text
□ internal/task/{task_name}.go + init RegisterJobHandler("TaskName", h)
□ handler 签名 (ctx, params) (string, error);长任务分批+上限;禁 HTTP 于事务内
□ 任务表配置:url="TaskName"、params JSON、cron(6 段或 # 5 段)、
execute_policy/is_sync;发布(status=1)
□ 验证:daemonStatus 挂载 + runOnce + job/log 成功记录
□ go build 重启(init 注册发生在进程加载期)
```
### 6. 自定义 / 扩展 dao
```text
□ dao/{m}.go 尾部"Add your custom methods"区追加方法(统计/联查等)
□ 修改模板本身:改 hack/tpl/dao_index.tpl 后 gf gen dao 重新生成
(overwriteDao:false 保留手工扩展;新旧模板产物差异需人工合并)
□ 红线:自定义方法也必须以 Ctx(ctx) 为查询入口;禁止在 dao 内做业务判断
```
### 7. 配置变更
```text
□ manifest/config/config.yaml 修改
□ 多环境差异用 GF_ 环境变量覆盖,不改文件
□ 新增配置键:注释说明 + 代码 cfgBool/cfgInt 带默认值读取(缺失不 panic)
□ 影响限流/日志/上传/调度的键变更后跑对应回归
```
## 逃生舱与扩展挂点
| 挂点 | 场景 | 说明 |
|------|------|------|
| BodyUnmarshaler | 请求体形态不规则(平铺 map 等) | Req 实现 `UnmarshalBody(r)` 跳过默认 Parse(自行做 gvalid) |
| ModuleSpec.Filter | 复杂查询条件 | 不与 Like/Eq 重复声明 |
| ModuleSpec.DataScope | 数据权限挂点 | 共享工厂 `ApplyDataScope(col)`,声明即生效 |
| ModuleSpec 钩子装不下 | 基座禁止按模块分支 | 整个方法手写(合法逃生舱,如 job.Detail 因嵌入结构手写) |
| logic 包级共享函数 | 跨模块复用 | 放具体模块文件顶部(同包可见);横切能力进 base.go(15 导出上限) |
| RegisterJobHandler | 本地任务处理器 | 全局注册表(重名 panic) |
| middleware wire | 业务与中间件解耦 | 三 Loader/Sink 函数变量 |
## 未启用项与前端遗留
| 项 | 现状 | 启用/处理要点 |
|----|------|-------------|
| 用户注册 | publicPaths 含 /api/register(路由未注册) | 启用时补建表逻辑 + 开放策略 + 租户归属规则 |
| 数据范围自定义档 | 现四档纯列值(role.data_scope) | 第 5 档需部门关联表 + 角色表单部门树多选 |
| 导出增强 | 当前导出返回临时文件 URL | 可加流式下载/异步导出 |
| SendSms 任务 | task/send_sms.go 仅记日志 | 接短信通道时替换实现 |
| 站内信/数据源/短信/OSS/WebSocket | 前端遗留死代码(后端桩已删除) | 不再调用即无影响 |
## 封板后的变更纪律
```text
1) 一切接口行为以 API 接口清单 + /api.json 为准,改动即更新文档
2) 表结构改动三同步:document/mysql/ 定稿 → 其余四方言脚本 → 迁移(跨环境时)
3) 破坏性变更(改名/删字段/改枚举值)必须走版本化说明 + 前端同步 +
黄金样本更新,禁止静默变更
4) 架构红线复查:绕过 dao / 手写租户条件 / 反向依赖 task / 超 15 导出的 base /
未登记 tenantTables——任何一项都应打回
```
@@ -0,0 +1,75 @@
# 4.4 前端分层架构
:::tip 概述
前端采用 Vue3 Composition API + TypeScript + Pinia + Element Plus,分层为:views(页面)→ components(公共组件)→ api(接口)→ utils/http(请求封装)→ store(状态)→ router(动态菜单)。本节给出分层视图与协作关系。
:::
## 前端分层视图
```text
┌─────────────────────────────────────────────────────────┐
│ views/{域}/{模块}/ 页面层 │
│ index.vue / edit.vue / detail.vue │
│ columns.ts(表格列) querySchemas.ts(筛选表单) │
└──────────────┬──────────────────────────────────────────┘
│ 组件/指令/组合式函数
┌──────────────▼──────────────────────────────────────────┐
│ components/ 公共组件层(25 类) │
│ Table Form Select Upload Editor Excel ChinaArea … │
│ directives/ 权限指令 v-permission 等 │
│ hooks/ core / event / setting / web │
└──────────────┬──────────────────────────────────────────┘
│ 接口调用
┌──────────────▼──────────────────────────────────────────┐
│ api/{域}/{模块}.ts 接口层(url 对齐 gmeta path) │
└──────────────┬──────────────────────────────────────────┘
│ http.request
┌──────────────▼──────────────────────────────────────────┐
│ utils/http/axios 请求封装层 │
│ baseURL=/api · JWT 注入 · 401 处理 · 信封解包 │
└──────────────┬──────────────────────────────────────────┘
│ 状态支撑
┌──────────────▼──────────────────────────────────────────┐
│ store/modules/ 状态层(Pinia) │
│ user(令牌/身份) · asyncRoute(动态菜单)… │
│ router/ 路由层 │
│ constant.ts(常量路由) · generator-routers.ts(动态) │
│ router-guards.ts(守卫) │
└─────────────────────────────────────────────────────────┘
```
## 数据流(以列表页为例)
```text
1. 路由守卫拦到未加载动态路由 → 调 /api/index/getMenus 拉菜单树 → 生成路由
2. 进入页面 index.vue:
querySchemas.ts 定义筛选表单 → 用户输入 → 拼 params
3. 调 api getExamplePage(params)
→ http 封装注入 Authorization: Bearer <access_token>
→ 后端返回统一信封,拦截器解包出 data(records/total/…)
4. columns.ts 驱动 Table 渲染;枚举列显示 {field}Text
5. 增删改走 edit.vue 表单 → exampleAdd/exampleUpdate → 成功刷新列表
```
## 关键机制索引
| 机制 | 位置 | 详见 |
|------|------|------|
| HTTP 封装(401 跳登录/令牌刷新) | utils/http/ | 9.4 |
| 动态菜单路由 | router/generator-routers.ts | 9.5 |
| 权限指令(按钮级) | directives/permission.ts | 9.7 |
| 登录多形态(账号/扫码/手机) | views/login/ | 9.8 |
| 三件套规范 | views/{域}/{模块}/ | 9.12 / 12.2 |
| 主题与设置 | settings/ + styles/ | 9.14 |
## 与后端的契约对齐
```text
api url(无 /api 前缀) ↔ gmeta path(/api 开头,无 /v1)
querySchemas field ↔ XxxPageReq json 字段(驼峰)
columns prop ↔ Item/DetailRes json 字段
权限指令参数 ↔ gmeta permission 权限码
登录读取 ↔ data.access_token(下划线例外)
```
对齐是联调问题的高发区,联调自检清单见 [3.9 前端联调指引](../guide/frontend-integration)。
@@ -0,0 +1,102 @@
# 4.1 整体架构概览
:::tip 概述
系统采用分层架构 + 声明式基座 + 全局钩子横切 + gmeta 反射注解的组合。本节给出总架构图、模块依赖方向(允许/禁止)与启动编排,是理解全部机制的地图。
:::
## 系统总架构图
```text
┌───────────────────────────── 客户端 ─────────────────────────────┐
│ 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 │
└───────────────┘ └──────────────┘ └─────────────────────────────┘
```
## 模块依赖方向图(允许 / 禁止)
```text
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)
```
## 启动编排(internal/cmd)
```text
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)
│ 中间件链注册 + 404/500 状态处理器 + SetServerRoot("resource")
│ + /api/health + /swagger → 逐个 Bind 全部控制器
├─ 4) go DaemonInit(延时 2s) 定时任务调度器加载 status=1 任务
├─ 5) gcron 注册 CleanTemp 每日 03:30 清理上传临时目录
└─ 6) s.Run() 阻塞运行 HTTP 服务
```
延时 2s 的原因:等 HTTP 监听就绪(任务可能是 HTTP 调自身接口)。
## 业务调用链(标准 CRUD)
```text
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/controller/logic/service 均单包,每模块一文件 | 跨模块直接同包调用(如 user 调 ResolveCityNames 无需 import);生成器目标路径唯一 |
| **service 接口手维护** | 接口即文档;禁止 gf gen service 整体覆盖 |
| **logic 同包 init() 注册** | 无需 blank import,包内全量加载 |
| **中间件不依赖业务层** | IdentityLoader/TenantLoader/LogSink 以函数变量接线(wire.go),cmd 启动期检查告警 |
| **dao.Ctx 唯一查询入口** | 软删/租户/审计三条红线在钩子层强制执行,业务零感知 |
## 分层职责速查
| 层 | 位置 | 职责 | 禁止 |
|----|------|------|------|
| api/v1 | api/v1/{模块}.go | 请求响应结构体 + gmeta 注解 | 业务逻辑 |
| controller | internal/controller/{模块}.go | 薄层透传 | 业务/dao/手写响应 |
| service | internal/service/{模块}.go | 接口契约(手维护) | 实现 |
| logic | internal/logic/{模块}.go | 业务实现(基座+钩子+手写) | 反向依赖 task |
| dao | internal/dao/ | 查询入口(钩子+红线条件) | 绕过直连 DB |
| hooks | internal/model/hooks/ | 审计盖章/软删/租户 | 业务判断 |
@@ -0,0 +1,110 @@
# 4.2 请求处理链路
:::tip 概述
一个受保护的写请求依次穿过七层中间件与 Bind 包装层。理解这条链路是排查 401/403/429/演示拦截/日志缺失的钥匙。注册顺序即执行顺序(router.go s.Use)。
:::
## 中间件链总览
```text
CORS → Response → RateLimit → Demo → JWT → Tenant → OperationLog → 处理器
(Bind 包装层做权限校验)
```
| # | 中间件 | 短路行为 |
|---|--------|---------|
| 1 | CORS | 预检 204;头设置 |
| 2 | Response | SVG 加固;元信息入 ctx;panic → 500 信封 |
| 3 | RateLimit | 超限 code=429 |
| 4 | Demo | `app.demo=true` 时写操作 code=1015 |
| 5 | JWT | 无效/黑名单 code=401 |
| 6 | Tenant | 租户禁用 1006 / 过期 1005 |
| 7 | OperationLog | 不短路,异步采集 |
| 8 | Bind 包装层 | 权限不足 code=403;参数校验失败 400 |
## 请求生命周期(12 步)
```text
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 组装记录 → 异步通道 → 批量落库
```
## 时序图(受保护写请求 + 日志异步落库)
```text
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
```
异常分支:
```text
* 任一中间件短路(429 / 演示拦截 / 401 / 403)→ 直接 WriteJson,后续不执行
* panic → Response recover → 500 信封(已写出响应则仅记录日志)
* logChan 满 → 丢弃并计数告警(每丢 100 条输出一次 Warning),绝不阻塞业务
```
## 各层关键实现速查
| 层 | 实现位置 | 要点 |
|----|---------|------|
| CORS | middleware/cors.go | `cors.allowOrigin` 空或 `*` 走框架默认;具体来源显式回显凭证 |
| Response | middleware/response.go | .svg 强制 attachment+nosniff(防存储型 XSS);realClientIP/UserAgent 入 ctx |
| RateLimit | middleware/ratelimit.go | 固定窗口;键 `ratelimit:{ip}:{path}:{窗口起点}`;计数经 utility/cache 多实例共享;存储异常放行 |
| Demo | middleware/demo.go | GET/HEAD/OPTIONS 与公开白名单放行;其余写操作拒绝 |
| JWT | middleware/jwt.go | Parse(HS256/过期/签发者)→ 黑名单 Exists → 注入身份(缓存 300s,回源 LoadIdentity) |
| Tenant | middleware/tenant.go | 缓存 60s;租户不存在按禁用处理;数据库基础错误放行(防存储抖动全站不可用) |
| OperationLog | middleware/operation_log.go | 有界通道 2048 + 单 worker;批量 100 条或 1s flush;参数脱敏 password/salt/token → *** |
| Bind | router/bind.go | 权限校验 + r.Parse(gvalid)+ 信封写出;BodyUnmarshaler 逃生舱 |
## 真实 IP 与受信代理
```text
util.go realClientIP:
直连来源不在 api.proxyIps(逗号分隔,精确 IP 与 CIDR)内 → 直接用 GetRemoteIp()
在列表内 → 才信任 X-Forwarded-For 首段 / X-Real-IP
```
防止伪造 XFF 绕过限流或污染日志。Nginx 反代部署务必把反代地址加入 `api.proxyIps`。
## 排障速查
| 症状 | 检查点 |
|------|--------|
| 401 | token 过期/黑名单(登出、轮换、强退都会拉黑 jti) |
| 403 | gmeta permission ↔ 菜单节点 ↔ 角色勾选 ↔ 身份缓存(300s) |
| 429 | 限流阈值;认证入口独立 authLimit=10 |
| 1015 | app.demo=true 拦截写操作 |
| 1005/1006 | 租户过期/禁用(Tenant 中间件,缓存 60s) |
| 日志缺失 | operationLog.enable / log 标签空 / logQuery 关 / 通道满告警 |
| panic 无堆栈 | server.errorStack=false;看服务端日志 |
@@ -0,0 +1,96 @@
# 4.10 安全架构设计
:::tip 概述
安全设计按威胁面组织:每项威胁对应明确控制措施与代码落点。本节给出威胁→控制总表、四条安全红线、部署基线与已知边界(诚实声明),可直接用于安全评审自查。
:::
## 威胁 → 控制总表
| 威胁面 | 控制措施 | 代码落点 |
|--------|---------|---------|
| 身份伪造 | HS256 签名校验 + iss/exp 强校验(限定 HS256 防算法混淆) | utility/jwt Parse |
| 令牌重放(登出后) | jti 黑名单,TTL=剩余有效期 | `blacklist:{jti}` |
| Refresh 重放 | 轮换:旧 refresh 入黑名单,已黑即拒(1008) | logic/auth Refresh |
| 被踢会话复活 | 强退同时拉黑 access+refresh 双令牌 | online_user ForceLogout |
| 口令窃取/泄露 | bcrypt(password+salt);日志脱敏(password/salt/token → ***) | utility/crypto; maskSecret |
| 暴力破解 | 登录/验证码/令牌兑换独立严阈值 10/min/IP | rateLimit.authLimit |
| 水平越权(跨租户) | dao.Ctx 注入 tenant_id IN(0,自身) | hooks.ApplyCtx |
| 行级越权(部门/本人) | 角色 data_scope 四档叠加过滤(仅列表/导出;写操作由 permission 兜底) | logic/data_scope.go |
| 垂直越权 | gmeta permission + HasPerm;超管放行 | Bind 包装层 |
| SQL 注入 | 全链 gdb 参数化;无方言拼接红线 | dao/Where 参数化 |
| 参数篡改 | gvalid 标签校验(Parse 统一触发) | api/v1 结构体标签 |
| 存储型 XSS | .svg 强制 attachment+nosniff;富文本外链不本地化、占位符契约 | middleware/response; base.go |
| SSRF | 富文本仅处理本站 URL,外站一律不抓取 | localizeOne 判定 |
| CSRF | Bearer 头令牌(非 Cookie)+ CORS 白名单 | JWT 中间件/CORS |
| IP 伪造 | 仅受信代理(api.proxyIps,支持 CIDR)的 XFF/X-Real-IP 生效 | realClientIP |
| 限流绕过 | 计数经 Redis 跨实例共享;窗口起点对齐 | RateLimit incrCounter |
| 越权写(演示站) | Demo 中间件拦截全部写操作 | middleware/demo |
| 审计缺失 | 操作日志异步全量记录(脱敏+响应+耗时) | OperationLog |
| 会话固定 | 无服务端 Cookie 会话,令牌全部服务端签发 | auth Login |
| 敏感信息出栈 | GetUser 永不返回 password/salt;令牌原文仅在线用户详情(需权限) | index/online_user |
## 四条租户隔离红线(安全审计重点)
::: warning 红线清单
1. 业务代码禁止 `g.DB()` 直查业务表(绕过钩子与过滤)
2. 业务代码禁止手写 del_flag / tenant_id 条件(应由 dao.Ctx 注入)
3. 新增租户级表必须登记 hooks.tenantTables(**漏登=跨租户泄漏**)
4. 唯一预检范围必须等于唯一索引范围(否则 1062 漏报/误报)
另:平台级(超管)全量可见是设计预期,管理端点须收紧 permission。
:::
## 文件上传安全链
```text
大小限制 → 扩展名白名单 → 图片魔数嗅探(前 512 字节须 image/*)
→ 随机文件名(防猜路径/覆盖)
→ 临时/正式两段式(未过校验不进正式目录)
→ 静态响应 svg 附件化 + nosniff
→ 定期清理 temp(每日 03:30,tempExpireDays 默认 7 天)
```
注意:fileExt 白名单含 js/css/sql 等——对公网用户提供上传时,建议按环境收紧 fileExt(配置层控制,无需改码)。
## 认证与授权安全细节
```text
认证:
* 令牌 HS256;密钥/签发者可配置;exp 必填
* 双令牌分工:access 2h 只作身份;refresh 7d 仅 /api/oauth2/token 可用(typ 校验)
* 验证码:一次性(命中即删)、TTL 300s、bypassCode 生产必须留空
* 密码:bcrypt DefaultCost;外部 salt 防彩虹;旧 md5 数据被 IsBcrypt 拒绝(强制重置)
授权:
* 权限点粒度到接口级(sys:module:action),按钮级由前端控制;
新增接口必须声明 permission 或显式留空(留空=登录即可,评审需明确该选择)
* 隐藏节点:menu hide=1 的权限节点不进 Perms——"禁用权限"用 status 而非 hide
* 权限缓存一致性:保存权限/分配角色/改数据范围主动清 identity 缓存,最迟 300s 过期
```
## 部署安全基线(生产)
```text
1) 修改 jwt.secret 为高强度随机值且与环境隔离
2) captcha.bypassCode=""; app.demo=false; app.errorMsg=false; errorStack=false
3) database/redis 使用独立账号与内网地址;不暴露公网
4) api.proxyIps 只列真实反代出口;未用反代时保持默认(含 127.0.0.1)
5) file.fileExt 按业务收紧;uploadDir 权限最小化;domainUrl 走 CDN/域名隔离
6) rateLimit 按压测调参(防误伤;authLimit 不建议调大)
7) HTTPS 由前置代理终止;服务本身 HTTP 内网
8) 定期核对 /api.json 与权限点清单(防新接口漏配 permission)
```
## 已知边界与取舍(诚实声明)
| 项 | 边界 |
|----|------|
| CheckUnique | 先查后插的 UX 预检,存在 TOCTOU——并发唯一性以 uk_* 索引为最终防线 |
| 数据权限 | 仅约束 Page/List/导出;Detail/Update/Delete 防线是 permission 节点 + 租户隔离 |
| 接线范围 | 数据权限当前仅 user 模块接线(dept_id 列);其他模块行级可见性由租户隔离单层兜底 |
| 部门子树 | deptSubtreeIds 每次全量拉部门表 BFS 无缓存——数百行内无感知,上万行需加短时缓存 |
| 身份缓存窗口 | 300s 内直接改库的 dept_id/data_scope 不经 API 不会即时生效(API 路径均主动清缓存) |
| 在线用户详情 | 返回令牌原文(便于运维排查),依赖 sys:onlineUser:detail 权限收口 |
| 日志截断 | param/result 截断 8KB,超长报文不完整留存 |
| 密码策略 | 无独立复杂度/定期更换强制(可按需在密码路径追加规则) |
| 防重放 | jti 黑名单依赖 TTL 自然过期,无物理防重放窗口 |