签入版本

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,194 @@
# 3.10 完整代码清单
:::tip 概述
本节汇总 example 模块的后端四件完整代码(api / controller / service / logic),全部摘自项目实际文件(含注释),可作为手动开发新模块的对照模板。dao 四件为工具产出(见 [3.3 DAO 数据层](./model)),此处不再罗列。
:::
## 文件对照表
| 文件 | 行数规模 | 职责 |
|------|---------|------|
| `api/v1/example.go` | ~200 行 | 全部 Req/Res + gmeta 注解 |
| `internal/controller/example.go` | ~90 行 | 薄层透传 |
| `internal/service/example.go` | ~75 行 | 手维护接口三件套 |
| `internal/logic/example.go` | ~200 行 | ModuleSpec 声明 + 委托 + 扩展方法 |
## 一、api/v1/example.go(节选:分页 + 详情 + 新增 + 修改)
```go
package v1
import (
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gmeta"
)
// ============================== 案例演示模块(goframe_example) ==============================
// ExampleItem 案例演示行数据
// 用于分页列表等场景的基础信息
type ExampleItem struct {
Id int `json:"id"` // 主键ID
Name string `json:"name"` // 案例名称
Avatar string `json:"avatar"` // 案例图片
Type int `json:"type"` // 案例类型
Status int `json:"status"` // 案例状态
Sort int `json:"sort"` // 案例排序
CreateUser string `json:"createUser"` // 创建人
CreateTime *gtime.Time `json:"createTime"` // 创建时间
StatusText string `json:"statusText"` // 状态显示名(dict_item 解析)
}
// ExamplePageReq 案例演示分页请求
type ExamplePageReq struct {
gmeta.Meta `method:"get" path:"/api/example/page" summary:"案例演示分页" permission:"sys:example:page" log:"案例演示-查询分页记录" logType:"query"`
PageReq
Name string `json:"name" dc:"案例名称"` // 案例名称
Type int `json:"type" dc:"案例类型"` // 案例类型
Status int `json:"status" dc:"案例状态"` // 案例状态
Keyword string `json:"keyword" dc:"关键词"` // 关键词
}
// ExamplePageRes 案例演示分页结果
// 统一壳:records/total/size/current/pages
type ExamplePageRes = PageData[ExampleItem]
// ExampleDetailReq 案例演示详情请求
type ExampleDetailReq struct {
gmeta.Meta `method:"get" path:"/api/example/detail/:id" summary:"案例演示详情" permission:"sys:example:detail" log:"案例演示-查询详情" logType:"query"`
Id int64 `json:"id" v:"required#缺少ID" dc:"记录ID"` // 记录ID
}
// ExampleDetailRes 案例演示详情结果
type ExampleDetailRes struct {
Id int64 `json:"id"` // 主键ID
Name string `json:"name"` // 案例名称
Avatar string `json:"avatar"` // 案例图片
Type int `json:"type"` // 案例类型
Status int `json:"status"` // 案例状态
Sort int `json:"sort"` // 案例排序
StatusText string `json:"statusText"` // 状态显示名(dict_item 解析)
}
// ExampleAddReq 新增案例演示请求
type ExampleAddReq struct {
gmeta.Meta `method:"post" path:"/api/example/add" summary:"新增案例演示" permission:"sys:example:add" log:"案例演示-新增记录" logType:"add"`
Name string `json:"name" v:"required#请输入案例名称" dc:"案例名称"` // 案例名称
Avatar string `json:"avatar" dc:"案例图片"` // 案例图片
Type int `json:"type" dc:"案例类型"` // 案例类型
Status int `json:"status" dc:"案例状态"` // 案例状态
Sort int `json:"sort" dc:"案例排序"` // 案例排序
}
// ExampleIdRes 新增返回ID结构
type ExampleIdRes struct {
Id int64 `json:"id"` // 主键ID
}
// ExampleUpdateReq 修改案例演示请求
type ExampleUpdateReq struct {
gmeta.Meta `method:"put" path:"/api/example/update" summary:"修改案例演示" permission:"sys:example:update" log:"案例演示-修改记录" logType:"update"`
Id int64 `json:"id" v:"required#缺少ID" dc:"记录ID"` // 记录ID
Name string `json:"name" v:"required#请输入案例名称" dc:"案例名称"` // 案例名称
Avatar string `json:"avatar" dc:"案例图片"` // 案例图片
Type int `json:"type" dc:"案例类型"` // 案例类型
Status int `json:"status" dc:"案例状态"` // 案例状态
Sort int `json:"sort" dc:"案例排序"` // 案例排序
}
// ExampleUpdateRes 修改案例演示结果
type ExampleUpdateRes struct{}
```
导入导出请求(multipart 与筛选复用):
```go
// ExampleImportReq 导入案例演示请求
type ExampleImportReq struct {
gmeta.Meta `method:"post" path:"/api/example/import" summary:"导入案例演示" permission:"sys:example:import" log:"案例演示-导入数据" logType:"import"`
File *ghttp.UploadFile `p:"file" dc:"导入文件"`
}
// ExampleExportReq 导出案例演示请求
// 筛选条件与分页一致,导出=所见
type ExampleExportReq struct {
gmeta.Meta `method:"get" path:"/api/example/export" summary:"导出案例演示" permission:"sys:example:export" log:"案例演示-导出数据" logType:"export"`
Name string `json:"name" dc:"案例名称"` // 案例名称
Type int `json:"type" dc:"案例类型"` // 案例类型
Status int `json:"status" dc:"案例状态"` // 案例状态
}
```
## 二、internal/controller/example.go(全文结构)
见 [3.6 控制器开发](./controller)——全部方法为一行透传 `return service.Example().Xxx(ctx, req)`。
## 三、internal/service/example.go(接口三件套)
见 [3.7 服务注册与路由](./route)——`IExample` 接口 + `localExample` 实例 + `Example()` 取用 + `RegisterExample()` 注册。
## 四、internal/logic/example.go(核心声明与扩展方法)
见 [3.5 逻辑层开发](./logic)。补充导入导出扩展方法(真实代码):
```go
// Import 导入案例演示
// 表头为表单字段名,按列序写入
func (s *sExample) Import(ctx context.Context, req *v1.ExampleImportReq) (res *v1.ExampleImportRes, err error) {
_, rows, err := excel.ReadSheet(req.File)
if err != nil {
return nil, err
}
res = &v1.ExampleImportRes{Total: len(rows)}
for i, row := range rows {
if cellAt(row, 0) == "" {
res.Failed++
res.Reasons = append(res.Reasons, fmt.Sprintf("第%d行:案例名称为空", i+2))
continue
}
if _, e := dao.Example.Ctx(ctx).Data(map[string]any{
"name": cellAt(row, 0),
"avatar": cellAt(row, 1),
"type": cellInt(row, 2, 0),
"status": cellInt(row, 3, 0),
"sort": cellInt(row, 4, 0),
}).Insert(); e != nil {
res.Failed++
res.Reasons = append(res.Reasons, fmt.Sprintf("第%d行:写入失败 %v", i+2, e))
continue
}
res.Success++
}
return res, nil
}
// Export 导出案例演示
// 与列表同套筛选条件,枚举列输出字典文本
func (s *sExample) Export(ctx context.Context, req *v1.ExampleExportReq) (res *v1.ExampleExportRes, err error) {
rows, err := exampleSpec.applyFilters(ctx, exampleSpec.Model(ctx), req).Order("sort, id").All()
if err != nil {
return nil, err
}
headers := []string{"案例名称", "案例图片", "案例类型", "案例状态", "案例排序"}
data := make([][]any, 0, len(rows))
for _, r := range rows {
data = append(data, []any{r["name"].String(), r["avatar"].String(), DictGetText(ctx, "example_type", r["type"]), DictGetText(ctx, "example_status", r["status"]), r["sort"].String()})
}
fileName, _, err := excel.WriteSheet(upload.BaseDir(ctx), "案例演示数据", headers, data)
if err != nil {
return nil, err
}
url := upload.GetFileUrl(ctx, fileName)
return &url, nil
}
```
## 使用方式
手动开发新模块时,以本清单为骨架逐文件替换模块名与字段;或直接用生成器产出同等结构后再微调(生成器模板即按此规范制作):
```sh
go run ./cmd/generator -tables "goframe_example|案例演示表"
go build ./... # 编译并重启
```
@@ -0,0 +1,99 @@
# 3.6 控制器开发
:::tip 概述
控制器是 HTTP 薄层,只做一件事:把解析好的请求转发给 service 接口。不写业务、不操作数据库、不组装复杂响应——所有逻辑在 logic 层。这个"薄"是有意为之:让权限、日志、参数解析统一收敛到 Bind 包装层。
:::
## 真实代码(摘自 internal/controller/example.go)
```go
package controller
import (
"context"
"easygoadmin/api/v1"
"easygoadmin/internal/service"
)
// ============================== 案例演示控制器 ==============================
// CExample 案例演示控制器
// 薄层控制器,仅负责参数透传,业务逻辑由 service.Example() 处理
type CExample struct{}
// Page 案例演示分页
// 按查询条件分页返回案例演示列表
func (c *CExample) Page(ctx context.Context, req *v1.ExamplePageReq) (res *v1.ExamplePageRes, err error) {
return service.Example().Page(ctx, req)
}
// List 案例演示平铺列表
// data 直接为数组,树形结构由前端 buildTree 构建
func (c *CExample) List(ctx context.Context, req *v1.ExampleListReq) (res v1.ExampleListRes, err error) {
return service.Example().List(ctx, req)
}
// Detail 案例演示详情
// 按主键ID查询详情
func (c *CExample) Detail(ctx context.Context, req *v1.ExampleDetailReq) (res *v1.ExampleDetailRes, err error) {
return service.Example().Detail(ctx, req)
}
// Add 新增案例演示
// 新增成功后返回新记录ID
func (c *CExample) Add(ctx context.Context, req *v1.ExampleAddReq) (res *v1.ExampleIdRes, err error) {
return service.Example().Add(ctx, req)
}
// Update 修改案例演示
// 按主键ID更新记录
func (c *CExample) Update(ctx context.Context, req *v1.ExampleUpdateReq) (res *v1.ExampleUpdateRes, err error) {
return service.Example().Update(ctx, req)
}
// Status 案例演示状态
// 设置记录的状态字段
func (c *CExample) Status(ctx context.Context, req *v1.ExampleStatusReq) (res *v1.ExampleStatusRes, err error) {
return service.Example().Status(ctx, req)
}
```
## 方法签名约定
```go
func (c *CXxx) 方法名(ctx context.Context, req *v1.XxxReq) (res *v1.XxxRes, err error)
```
这是 Bind 反射绑定的硬约束:**入参必须是指针请求结构体,出参必须是(结果指针, error)**。签名不匹配的方法不会被注册路由(静默跳过),排障时注意。
## 命名规则
| 项 | 规则 | 示例 |
|----|------|------|
| 控制器类型 | `C` + 模块 Pascal | `CExample`、`CConfigItem` |
| 文件名 | 表名去前缀 snake_case | `example.go`、`config_item.go` |
| 方法名 | 与 logic/接口方法名一致 | `Page`/`Add`/`BatchDelete` |
| 包 | 单包 `controller`,平铺放置 | 无子目录 |
## 控制器里禁止做的事
::: warning 薄层纪律
1. **禁止写业务逻辑**——if/for/计算全部下沉 logic。
2. **禁止直接 import dao**——数据访问只在 logic。
3. **禁止手写响应信封**——Bind 包装层统一 `response.Ok/Fail` 包装。
4. **禁止手写参数解析/权限判断**——`r.Parse` 与 `HasPerm` 在包装层完成。
5. 需要组合多个 service 调用时,逻辑放 logic 层的一个聚合方法,controller 仍只透传。
:::
## 特殊控制器参考
| 控制器 | 特点 |
|--------|------|
| `CAuth` | 登录/登出/验证码/令牌,gmeta 带 `public:"true"` |
| `CIndex` | 个人中心聚合(getUser/getMenus/updatePassword/updateProfile) |
| `COnlineUser` | 无表模块,数据源 Redis |
| `CUpload` | multipart 上传(`*ghttp.UploadFile` + `p:"file"` 标签) |
| `CGenerator` | 工具类,Preview/Generate/BatchGenerate |
控制器不区分"标准/特殊"写法——统一薄层透传,差异全部体现在请求结构体的 gmeta 注解上。
@@ -0,0 +1,107 @@
# 3.2 数据库建表
:::tip 概述
建表是模块开发的第一步。本项目对表结构有强约束:统一审计列、int 自增主键、del_flag 软删、uk_* 唯一索引、列注释枚举。这些规范不是建议——它们是全局钩子、字典系统、代码生成器正常工作的前提。
:::
## 统一规范(25 张表齐备)
```sql
-- 审计列(每张表必有)
id int NOT NULL AUTO_INCREMENT COMMENT '主键ID', -- 数据库自增(禁用雪花)
create_user varchar(50) DEFAULT '' COMMENT '创建人',
create_time datetime DEFAULT (now()) COMMENT '创建时间',
update_user varchar(50) DEFAULT '' COMMENT '更新人',
update_time datetime DEFAULT (now()) COMMENT '更新时间',
del_flag int DEFAULT 0 COMMENT '删除标志:0-正常 1-已删除',
-- 租户级表(9 张)另有:
tenant_id int DEFAULT 0 COMMENT '租户ID:0-平台级',
PRIMARY KEY (id)
```
关键决策(与直觉不同的点):
::: warning 关键约束
1. **主键统一 int 数据库自增**(2026-10-13 定稿):禁用雪花算法(`utility/id` 已删除);新增后用 `result.LastInsertId()` 回读;hooks 不填 id。
2. **删除一律软删**:deleteHook 把物理删除改写为 `UPDATE del_flag=1`,查询由 `dao.Ctx` 恒过滤 `del_flag=0`。
3. **无物理外键**:全部逻辑外键(如 article.category_id),跨表一致性由业务层保证。
:::
## 唯一索引(uk_*,13 个定稿)
唯一索引是并发唯一性的最终保证(`UniqueFields` 预检仅为 UX 层,存在 TOCTOU 竞态)。命名规范 `uk_` 前缀:
```sql
-- 示例:goframe_user 的三个唯一索引(均含 del_flag,软删行不占坑)
UNIQUE KEY uk_tenant_username (tenant_id, username, del_flag)
UNIQUE KEY uk_tenant_mobile (tenant_id, mobile, del_flag)
UNIQUE KEY uk_tenant_code (tenant_id, code, del_flag)
-- 全局唯一(平台级表)
UNIQUE KEY uk_code (code) -- config / dict / param / file_template / tenant
UNIQUE KEY uk_tenant_name (tenant_id, name, del_flag) -- dept / level / position / category
```
::: tip 索引范围纪律
含 tenant_id 的唯一索引,模块唯一预检(UniqueFields 的 `Group:"tenant_id"` 或手写检查按行现租户)**范围必须等于索引范围**,否则漏报 1062 或跨租户误报。
:::
## 列注释枚举(字典与生成器的数据源)
注释写法 `字段名:1-正常 2-禁用`(`-`、`—`、`=`、`=` 均可作分隔符),生成器解析为前端选项并自动建字典(编码规范 `{模块驼峰}_{字段驼峰}`):
```sql
status int DEFAULT 1 COMMENT '状态:1-正常 2-禁用',
-- 常见枚举定稿:
-- article.status 0-正常 1-下架
-- 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-仅本人
-- login_log.log_type 1-登录 2-退出
```
## 案例演示表(goframe_example)
```sql
CREATE TABLE `goframe_example` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(100) NOT NULL DEFAULT '' COMMENT '案例名称',
`avatar` varchar(255) NOT NULL DEFAULT '' COMMENT '案例图片',
`type` int NOT NULL DEFAULT 0 COMMENT '案例类型',
`status` int NOT NULL DEFAULT 1 COMMENT '状态:1-正常 2-禁用',
`sort` int NOT NULL DEFAULT 0 COMMENT '排序',
`create_user` varchar(50) NOT NULL DEFAULT '' COMMENT '创建人',
`create_time` datetime NULL DEFAULT (now()) COMMENT '创建时间',
`update_user` varchar(50) NOT NULL DEFAULT '' COMMENT '更新人',
`update_time` datetime NULL DEFAULT (now()) COMMENT '更新时间',
`del_flag` int NOT NULL DEFAULT 0 COMMENT '删除标志:0-正常 1-已删除',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='案例演示表';
```
它演示了最小可用表形态:审计列齐备、status 注释枚举(生成器据此自动创建 `example_status` 字典)、avatar 图片字段(注释含"图片"触发生成器图片识别)。
## 建表/变更工作流
```text
1) 以 document/mysql/ 定稿为基准补 ALTER
(含列注释枚举、uk_* 唯一索引;索引命名 uk_/idx_ 规范)
2) gf gen dao -c hack/config.yaml 刷新 dao/entity/do(service 手维护)
3) 同步 document/ 其余四方言脚本(人工校对)
4) 租户级表 → 修改 internal/model/hooks 的 tenantTables 清单
(生成器不自动维护此清单!)
5) go build ./... 重启;必要时 go run ./cmd/migrator 同步到其他环境库
```
## 租户级表判定
新增表是否带 `tenant_id` 决定它是否需要加入 `internal/model/hooks/hooks.go` 的 `tenantTables` 清单:
| 类型 | 现有表 | 行为 |
|------|--------|------|
| 租户级(9 张) | user/role/dept/level/position/article/category/notice/link | 自动过滤 `tenant_id IN(0,自身)`、插入自动盖章 |
| 平台级 | 其余 16 张 | 无租户过滤(超管可见全量) |
漏加清单的后果:租户级表不隔离,所有租户互相可见数据——务必同步。
@@ -0,0 +1,89 @@
# 3.11 常见问题与排错
:::tip 概述
本节收录模块开发阶段的高频问题,按「路由/编译/数据/权限/生成器」分组。更多通用排障见 [附录 A](../appendix/faq)。
:::
## 路由与注册
### Q:新增接口 404
排查顺序:
```text
1. router.go 是否有 Bind(s, new(controller.CXxx)) 注册行(生成器会自动写)
2. 请求结构体是否内嵌 gmeta.Meta 且 method/path 标签正确
3. 控制器方法签名是否为 (ctx, *v1.XxxReq) (*v1.XxxRes, error)——签名不符静默跳过
4. 路径是否误带 /v1(本项目 URL 无版本段)
```
验证:`curl http://127.0.0.1:8000/api.json | grep "xxx"`——gmeta 会补注册进 OpenAPI。
### Q:取用 service panic `implement not found`
logic 的 `init()` 未调用 `service.RegisterXxx`,或接口文件的取用函数名与注册函数名不匹配。检查三件套(接口/取用/注册)拼写一致。
## 编译与结构
### Q:`go build` 报 logic 循环引用
logic 是单包,跨模块直接调用包级函数即可,不要在 logic 内 import 其他会 import logic 的包。依赖红线:`task → logic` 单向允许;`logic → task` 禁止;middleware 经 wire.go 函数变量接线(不 import 业务包)。
### Q:改动 logic 方法签名后编译通过但运行 403/空响应
service 接口是手工维护的——签名变更必须同步 `internal/service/{模块}.go`,否则 controller 调用的是旧接口(编译期不报错的场景是接口方法恰好同签名但语义变化)。
### Q:新增了字段但接口返回里没有
三处都要检查:entity 是否刷新(`gf gen dao`)→ Req/Res 结构体是否含该字段 → ToData 是否映射(写侧)。读侧展示字段在 Item/DetailRes 声明即可(Struct 自动映射)。
## 数据与隔离
### Q:新增数据 tenant_id 为 0 或报错
租户级表必须把表名加入 `internal/model/hooks/hooks.go` 的 `tenantTables` 清单(生成器不自动维护)。插入行为:租户用户零值自动填自身;超管未指定填 `tenant.defaultId`;显式 0 建平台共享行。
### Q:查询结果包含已删除数据
是否绕过了 `dao.{X}.Ctx(ctx)` 直连 `g.DB()`?红线:唯一查询入口是 dao.Ctx,它自动注入 `del_flag=0`。
### Q:唯一校验漏报 1062 或跨租户误报
`UniqueFields` 的 `Group` 范围必须等于唯一索引范围:索引含 tenant_id 时必须 `Group:"tenant_id"`。手写检查(如 user 的 username/mobile)按"被改行现租户"校验。
### Q:删除报"存在下级或关联"
模块 BeforeDelete 守卫(如 dept 有子级/用户、user 关联清理 user_role)。这是业务保护,需要先处理关联数据。
## 权限与日志
### Q:新接口一直 403
```text
1. gmeta permission 标签是否声明(空则仅需登录)
2. 菜单里权限节点是否存在(sys:{module小驼峰}:{action})
3. 角色是否勾选该节点并保存(保存会清身份缓存)
4. 测试账号是否重新登录(身份缓存 300s;可 DEL easygoadmin:user:identity:{uid})
```
### Q:操作日志没记录
检查:gmeta `log` 标签是否为空(空不记录);`logType:"query"` 且 `operationLog.logQuery: false` 时查询类跳过;`operationLog.enable` 总开关;通道满会丢弃(每丢 100 条告警一次)。
## 生成器
### Q:生成器不创建字典
枚举注释格式不对——须为 `字段名:1-正常 2-禁用` 形式(`-`/`—`/`=`/`=` 分隔)。非 MySQL 库内省不到注释,建议在 MySQL 上生成。
### Q:生成的前端页面路由 404
菜单由生成器创建(path `/{domain}/{moduleCamel}`),但角色未勾选新菜单;或 component 路径与实际文件不符(domain 归属由 genDomainMap 决定)。
### Q:重复生成会覆盖我的手工修改吗
默认不会——`generator.overwrite: false` 时已存在文件跳过(计入 Skipped)。`overwrite: true` 时全部覆盖(计入 Overwritten),批量覆盖前务必备份或临时改回 false。
### Q:租户级新表生成后数据不隔离
生成器不维护 `tenantTables` 清单,需手工把表名加入 `internal/model/hooks/hooks.go` 后重启。
@@ -0,0 +1,183 @@
# 3.9 前端联调指引
:::tip 概述
前端每个列表模块由三件套组成:`api/{域}/{模块}.ts`(接口)、`views/{域}/{模块}/columns.ts`(表格列)、`views/{域}/{模块}/querySchemas.ts`(筛选表单)。三者与后端 gmeta/Req 字段严格对齐,本节以 example 模块真实代码演示。
:::
## 接口模块(真实代码,摘自 ui/src/api/tool/example.ts)
```ts
import { http } from '@/utils/http/axios';
/**
* 案例演示模块接口
*
* 提供案例演示的增删改查及导入导出接口调用。
*/
/**
* 获取案例演示分页列表
*
* @param params 查询参数,包含 pageNo、pageSize 及业务筛选条件
* @returns 分页结果,含 records、total、size、current、pages
*/
export function getExamplePage(params?: Record<string, any>) {
return http.request({
url: '/example/page',
method: 'GET',
params,
});
}
/**
* 根据 ID 获取案例演示详情
*
* @param id 案例演示ID
* @returns 案例演示详情数据
*/
export function getExampleDetail(id) {
return http.request({
url: '/example/detail/' + id,
method: 'get',
});
}
/**
* 添加案例演示
*
* @param data 案例演示数据
* @returns 新记录的 ID
*/
export function exampleAdd(data: any) {
return http.request({
url: '/example/add',
method: 'POST',
data,
});
}
```
要点:url 不含 `/api`(axios baseURL 已是 `/api`);GET 用 `params`、写操作用 `data`;路径与后端 gmeta path 逐条对齐。
## 筛选表单(真实代码,摘自 views/tool/example/querySchemas.ts)
```ts
import { FormSchema } from '@/components/Form/index';
/**
* 案例演示查询表单配置
*
* 定义列表页搜索区域的表单项,字段来自 filter_fields,
* 按字段类型自动选择组件:
* - 字符型用 Input;
* - 带选项的字段用 Select;
* - 纯数字字段用 InputNumber;
* - 日期字段用 DatePicker。
*/
export const schemas: FormSchema[] = [
{
// 案例名称
field: 'name',
component: 'Input',
label: '案例名称',
componentProps: {
placeholder: '请输入案例名称',
clearable: true,
},
},
{
// 案例类型
field: 'type',
component: 'Select',
label: '案例类型',
componentProps: {
placeholder: '请输入案例类型',
clearable: true,
// 选项列表:值用于查询,标签用于展示
options: [
{ label: '类型1', value: 1 },
{ label: '类型2', value: 2 },
{ label: '类型3', value: 3 },
{ label: '类型4', value: 4 },
],
},
},
];
```
## 筛选对齐三件套(2026-10-14 定稿)
```text
前端 querySchemas.ts 每个输入框
↔ XxxPageReq 同名驼峰字段
↔ logic spec 声明
字符串 Input → LikeFields(逐列模糊,值取 req 同名字段)
Select/InputNumber → EqFields(等值,零值跳过)
关键词框 → KeywordFields(能力保留在基座,模块按需声明)
```
example 对应关系:`querySchemas` 的 `name`(Input)↔ `ExamplePageReq.Name` ↔ `LikeFields: []string{"name"}`;`type/status`(Select)↔ EqFields。**导出统一走 `spec.applyFilters` 与列表同套筛选**(导出=所见)。
## 表格列(真实代码,摘自 views/tool/example/columns.ts)
```ts
import { h } from 'vue';
import { ElAvatar, ElTag } from 'element-plus';
/**
* 案例演示表格列配置
*
* 定义列表页表格的显示列,包含多选框、ID、各业务字段及审计字段。
* 字段按类型自动选择渲染方式:图片用 ElAvatar、选项用 ElTag、
* 链接用 a 标签,其余为普通文本。
*/
export const columns = [
{
type: 'selection', // 多选框列
},
{
label: 'ID',
prop: 'id',
fixed: 'left',
width: 50,
},
{
label: '案例图片',
prop: 'avatar',
minWidth: 120,
// 图片渲染:以方形头像形式展示
render(record) {
return h(ElAvatar, {
size: 48,
src: record.row.avatar,
shape: 'square',
fit: 'fill',
});
},
},
// …
];
```
枚举列显示 `{field}Text`(如 `statusText`,由后端字典填充),保证文案唯一真相在字典管理页。
## 页面文件组织
```text
views/tool/example/
├── index.vue # 列表页(分页表格 + querySchemas 筛选区 + 增删改入口)
├── edit.vue # 新增/编辑弹窗表单
├── detail.vue # 详情视图
├── columns.ts # 表格列配置
└── querySchemas.ts # 筛选表单配置
```
## 联调自检清单
+ [ ] api 模块 url 与后端 gmeta path 一致(无 `/v1`,baseURL 补 `/api`)
+ [ ] querySchemas field 名与 XxxPageReq json 字段驼峰一致
+ [ ] 字符串筛选项后端已声明 LikeFields,Select/InputNumber 已声明 EqFields
+ [ ] 枚举列展示 {field}Text;字典编码与 spec SerializeMaps 一致
+ [ ] 文件字段读侧已由基座补完整 URL,直接绑定展示组件
+ [ ] 前端按钮已加权限指令(对齐 gmeta permission)
@@ -0,0 +1,187 @@
# 3.5 逻辑层开发
:::tip 概述
逻辑层是业务核心。本项目采用 ModuleSpec 泛型声明式基座:查询条件、排序、字典、文件字段、唯一校验、数据权限全部进声明,方法只做三行委托;写不进声明的差异进钩子;钩子也装不下的合法逃生舱是手写方法。
:::
## 逻辑层文件结构(internal/logic/{模块}.go)
```text
1. 包注释 + import
2. 类型 sXxx{}(实现 service.IXxx 接口)
3. init() 中 service.RegisterXxx(&sXxx{}) ← 同包自动注册,无需 blank import
4. var xxxSpec = ModuleSpec[...]{...} ← 差异全部进声明
5. ToData 转换函数(新增/修改共用)
6. 标准方法:三行委托基座
7. 扩展方法:手写实现(Status/Import/Export 等非标准接口)
```
## ModuleSpec 声明(真实代码,摘自 internal/logic/example.go)
```go
// ============================== 案例演示业务实现 ==============================
// sExample 案例演示业务实现
// 实现 service.IExample 接口,接口定义见 api/v1/example.go
type sExample struct{}
func init() {
service.RegisterExample(&sExample{})
}
// exampleSerializeMaps 枚举字段到字典编码的映射
// {field}Text 由基座自动填充
var exampleSerializeMaps = map[string]string{
"status": "example_status",
}
// exampleSpec 案例演示模块声明
//
// 采用声明式实现:查询、输出与规则均在声明中定义,方法仅做委托
var exampleSpec = ModuleSpec[v1.ExampleItem, v1.ExampleItem, v1.ExampleDetailRes]{
Model: dao.Example.Ctx,
LikeFields: []string{"name"},
EqFields: []string{"type", "status"},
OrderBy: "sort, id",
SerializeMaps: exampleSerializeMaps,
FileFields: []string{
"avatar",
},
FileSaveDir: "example",
ToData: exampleToData,
}
```
泛型三参数:`ModuleSpec[Item, LItem, DRes]` = 分页行类型 / 列表行类型 / 详情类型(列表与分页同型时写同一个类型)。
## ToData 全量表单语义转换(真实代码)
```go
// exampleToData 全量表单语义转换
// 新增与修改均提交全部字段,兼容两种入参类型
func exampleToData(req any) map[string]any {
switch r := req.(type) {
case *v1.ExampleAddReq:
return map[string]any{
"name": r.Name,
"avatar": r.Avatar,
"type": r.Type,
"status": r.Status,
"sort": r.Sort,
}
case *v1.ExampleUpdateReq:
return map[string]any{
"name": r.Name,
"avatar": r.Avatar,
"type": r.Type,
"status": r.Status,
"sort": r.Sort,
}
}
panic(fmt.Sprintf("exampleToData: 不支持的入参类型 %T", req))
}
```
## 标准方法:三行委托(真实代码)
```go
// Page 案例演示分页
// 按查询条件分页返回案例演示列表
func (s *sExample) Page(ctx context.Context, req *v1.ExamplePageReq) (res *v1.ExamplePageRes, err error) {
list, total, err := exampleSpec.Page(ctx, req, req.GetPageNo(), req.GetPageSize())
if err != nil {
return nil, err
}
return v1.NewPageData(req.GetPageNo(), req.GetPageSize(), total, derefSlice(list)), nil
}
// Detail 案例演示详情
// 按主键ID查询详情
func (s *sExample) Detail(ctx context.Context, req *v1.ExampleDetailReq) (res *v1.ExampleDetailRes, err error) {
return exampleSpec.Detail(ctx, req.Id, req)
}
// Add 新增案例演示
// 新增成功后返回新记录ID
func (s *sExample) Add(ctx context.Context, req *v1.ExampleAddReq) (res *v1.ExampleIdRes, err error) {
id, err := exampleSpec.Add(ctx, req)
if err != nil {
return nil, err
}
return &v1.ExampleIdRes{Id: id}, nil
}
// Update 修改案例演示
// 按主键ID更新记录
func (s *sExample) Update(ctx context.Context, req *v1.ExampleUpdateReq) (res *v1.ExampleUpdateRes, err error) {
if err = exampleSpec.Update(ctx, req.Id, req); err != nil {
return nil, err
}
return &v1.ExampleUpdateRes{}, nil
}
```
## 扩展方法:手写实现(真实代码)
```go
// Status 案例演示状态
// 非标准方法,保留手写实现,设置记录的状态字段
func (s *sExample) Status(ctx context.Context, req *v1.ExampleStatusReq) (res *v1.ExampleStatusRes, err error) {
if _, err = dao.Example.Ctx(ctx).Where("id", req.Id).Data(map[string]any{"status": req.Status}).Update(); err != nil {
return nil, err
}
return &v1.ExampleStatusRes{}, nil
}
```
## 声明字段速查
| 字段 | 作用 |
|------|------|
| `Model` | 数据源,通常 `dao.Xxx.Ctx` |
| `LikeFields` | 逐列 LIKE(值取 req 同名驼峰字段,空跳过) |
| `EqFields` | 等值过滤(零值跳过) |
| `KeywordFields` | req.keyword 多列 OR(能力保留在基座,模块默认不声明) |
| `Filter` | 复杂条件逃生舱 `func(ctx, m, req) *gdb.Model` |
| `OrderBy` / `ListOrderBy` | 默认排序;`OrderableFields` 前端排序白名单 |
| `IsTree/ParentOf/ChildSetter` | 树形(menu/dept/category 使用) |
| `SerializeMaps` | {field}Text 字典填充映射 |
| `FileFields/MultiFileFields/ContentFields/FileSaveDir` | 文件/富文本声明(写转正、读补 URL) |
| `UniqueFields` | 唯一性预检 `[]UniqueRule{Field, Group}`(Group 如 `"tenant_id"`) |
| `DataScope` | 行级数据权限,`ApplyDataScope("dept_id")` 一行接线 |
| `BeforeAdd/AfterAdd/BeforeUpdate/AfterUpdate/BeforeDelete/AfterDelete/BeforeBatchDelete/AfterBatchDelete/AfterPageList/AfterDetail` | 生命周期钩子 |
| `ToData` | req → map[string]any 转换 |
## 基座流水线(CRUD 落点,横切逻辑注入点标注)
```text
Page/List:
Model(ctx) ─▶ LikeFields ─▶ EqFields ─▶ KeywordFields ─▶ Filter ─▶ DataScope
└─ 前置:dao.Ctx 已注入 del_flag=0 + 租户 IN(0,自身)
─▶ Count ─▶ applyOrder(白名单) ─▶ .Page(pageNo,pageSize) ─▶ structRows[T]
─▶ readTransform:FileFields 补URL → MultiFileFields 补URL
→ ContentFields 展开[IMG_URL] → FillTextFields({field}Text)
─▶ AfterPageList(禁 append)/IsTree 时 List 走 buildTree
Add:
ToData(req) ─▶ BeforeAdd ─▶ saveTransform(temp→正式, 富文本占位符化)
─▶ checkUniqueRules(UniqueFields 预检,Group 支持分组唯一)
─▶ Insert(hooks: 盖章 create_*/del_flag/tenant_id,主键自增)
─▶ LastInsertId ─▶ AfterAdd ─▶ 返回 {id}
Update:
ToData(req) ─▶ BeforeUpdate(可用 MergeOld 补零值)─▶ saveTransform
─▶ checkUniqueRules(排除自身) ─▶ Update(仅更新 data 键,hooks 盖章)
─▶ AfterUpdate
Delete / BatchDelete:
BeforeDelete(守卫: 存在性/子级/引用) ─▶ Delete(deleteHook 改写 del_flag=1)
─▶ AfterDelete;批量为逐条循环 + Before/AfterBatchDelete
```
```
::: warning 钩子纪律
1. 钩子内可改元素/data,**禁止 append、禁止 HTTP/文件 IO**(文件迁移统一在 saveTransform)。
2. 钩子错误记 stage 日志后原样上抛(保留 gerror 语义码)。
3. 基座禁止"按模块分支"——差异进不了声明与钩子就手写,这是合法逃生舱。
:::
@@ -0,0 +1,109 @@
# 3.3 DAO 数据层
:::tip 概述
DAO 四件(dao/{m}.go、dao/internal/{m}.go、entity、do)由 `gf gen dao` 或代码生成器产出,无需手写。但 `dao.{X}.Ctx(ctx)` 是全系统**唯一查询入口**(架构红线),理解它的两行自定义模板逻辑至关重要。
:::
## 四件产出与职责
| 文件 | 产出方式 | 职责 |
|------|---------|------|
| `internal/dao/{m}.go` | 自定义模板 hack/tpl/dao_index.tpl | 挂全局钩子 + Ctx 覆写(红线执行点) |
| `internal/dao/internal/{m}.go` | gf gen dao | 内部实现(列常量/表名/基础查询),勿手改 |
| `internal/model/entity/{m}.go` | gf gen dao | 实体结构体(对齐表列) |
| `internal/model/do/{m}.go` | gf gen dao | 数据对象 DO(Insert/Update 入参形态) |
## 真实代码(internal/dao/example.go,摘自项目)
```go
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// 自定义模板:hack/tpl/dao_index.tpl
// - 构造时挂载全局模型钩子(审计字段 / 软删除改写;主键交数据库自增)
// - Ctx 覆写注入软删除与租户隔离条件(架构红线执行点)
// =================================================================================
package dao
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"easygoadmin/internal/dao/internal"
"easygoadmin/internal/model/hooks"
)
// exampleDao is the data access object for the table goframe_example.
// You can define custom methods on it to extend its functionality as needed.
type exampleDao struct {
*internal.ExampleDao
}
var (
// Example is a globally accessible object for table goframe_example operations.
Example = exampleDao{
internal.NewExampleDao(func(m *gdb.Model) *gdb.Model {
// 全局钩子:Insert 补齐审计字段/租户ID(主键自增);Update 盖章;Delete 改写为软删除
return m.Hook(hooks.Handler())
}),
}
)
// Ctx 覆写:在 SQL 构建之前注入架构红线条件 ——
// 所有表追加 del_flag=0;租户级表对租户上下文追加 tenant_id IN (0, 当前租户)。
// 业务代码必须经由本方法访问模型,禁止绕过 dao 直连 DB。
func (d *exampleDao) Ctx(ctx context.Context) *gdb.Model {
return hooks.ApplyCtx(ctx, d.ExampleDao.Ctx(ctx), "goframe_example")
}
// Add your custom methods and functionality below.
```
## Ctx 覆写做了什么(hooks.ApplyCtx)
```text
dao.Example.Ctx(ctx)
│
├─ 所有表: WHERE del_flag = 0
└─ 租户级表且非平台上下文:WHERE tenant_id IN (0, 当前租户)
(租户用户看自己 + 平台共享行;超管 ctx tenant_id=0 不加过滤,全量可见)
```
## 全局钩子行为(hooks.Handler)
| 钩子 | 行为 |
|------|------|
| Insert | 主键交数据库自增(不填 id);补 del_flag=0、create_time、create_user(ctx 用户名,回退真实姓名);租户级表自动携带 tenant_id(租户用户零值→填自身;超管未指定→`tenant.defaultId`;显式 0 可建平台共享行) |
| Update | 自动盖章 update_time / update_user(仅 map 数据生效) |
| Delete | 物理删除一律改写为 `UPDATE del_flag=1`(软删) |
## 生成方式
```sh
# 方式一:表结构变更后刷新已实现模块的 dao
gf gen dao -c hack/config.yaml
# 方式二:新模块随生成器一次性产出(dao 四件与 gf gen dao 同构,生成后统一 gofmt)
go run ./cmd/generator -tables "goframe_example|案例演示表"
```
::: warning 三条红线
1. **禁止绕过 dao 直连 `g.DB()`**——软删/租户/审计全部失效。
2. **禁止手写 del_flag/tenant_id 条件**——`Ctx` 已自动注入,手写必错(漏平台行/漏软删行)。
3. **禁止手改 dao/internal/**——重新生成即被覆盖;自定义方法写在 `dao/{m}.go` 尾部。
:::
## 事务中的 dao
`WithTrans(ctx, fn)` 包裹的事务上下文内,`dao.{X}.Ctx(ctx)` 自动走事务连接:
```go
// internal/logic/base.go 的事务包裹(摘自项目)
// WithTrans 事务包裹(错误自动回滚)
func WithTrans(ctx context.Context, fn func(ctx context.Context) error) error {
// g.DB().Begin → fn(gdb.WithTX(ctx, tx)) → 出错 Rollback,否则 Commit
}
```
纪律:事务内禁止 HTTP/文件 IO;bcrypt 等 CPU 密集操作在事务外预计算(典型:租户开户、用户新增)。
@@ -0,0 +1,78 @@
# 3.1 概述与目标
:::tip 概述
本章以「案例演示模块(example)」为主线,完整走一遍模块开发全流程:建表 → DAO → 请求与校验 → 逻辑层 → 控制器 → 服务注册与路由 → 权限节点 → 前端联调。章内所有代码均摘自项目真实实现,可直接对照源码阅读。
:::
## 本章目标
完成本章后,你将能够:
```text
1. 设计符合项目规范的业务表(统一审计列 / del_flag / uk_* 唯一索引 / 注释枚举)
2. 产出并理解 dao/entity/do 四件(gf gen dao 或生成器)
3. 编写 api/v1 请求响应结构体与 gmeta 注解(权限/日志/OpenAPI 一体)
4. 用 ModuleSpec 声明式基座实现逻辑层(筛选/字典/文件字段/钩子)
5. 完成薄层控制器 + 手维护 service 接口 + 路由注册
6. 配置权限节点并完成前端 querySchemas/columns/api 三件套联调
```
## 模块开发的两条路径
| 路径 | 适用场景 | 说明 |
|------|---------|------|
| 代码生成器 | 标准 CRUD 模块 | 一条命令产出全部文件 + 菜单/权限/字典/路由副作用(见第 10 章) |
| 手动编写 | 复杂业务模块 | 按本章步骤逐件编写,完全掌控每行代码 |
两条路径产出的结构完全一致——生成器模板就是按本章规范制作的。建议先读懂本章,再用生成器提效。
## 模块文件全景(以 example 为例)
一个模块在后端落地需要以下文件(★ 为手工编写,○ 为工具产出):
```text
○ internal/dao/example.go 数据访问(自定义模板:挂钩子 + Ctx 覆写)
○ internal/dao/internal/example.go dao 内部实现(勿手改)
○ internal/model/entity/example.go 实体
○ internal/model/do/example.go 数据对象 DO
★ api/v1/example.go 全部 Req/Res 结构体 + gmeta 注解
★ internal/controller/example.go 薄层控制器(转发 service)
★ internal/service/example.go 手维护接口 + Register + 取用函数
★ internal/logic/example.go 业务实现(ModuleSpec 声明 + 方法委托)
★ internal/router/router.go 追加一行 Bind 注册(生成器自动写入)
★ ui/src/api/tool/example.ts 前端接口模块
★ ui/src/views/tool/example/ 前端页面五件套
index.vue / edit.vue / detail.vue / columns.ts / querySchemas.ts
```
## 标准接口清单(七件套 + 扩展)
| 方法 | HTTP | 路由模板 | 说明 |
|------|------|---------|------|
| Page | GET | /api/example/page | 分页(pageNo/pageSize + 筛选) |
| List | GET | /api/example/list | 全量/树形(下拉、树选择) |
| Detail | GET | /api/example/detail/:id | 详情 |
| Add | POST | /api/example/add | 新增,返回 {id} |
| Update | PUT | /api/example/update | 修改(body 含 id) |
| Delete | DELETE | /api/example/delete/:id | 软删(del_flag=1) |
| BatchDelete | DELETE | /api/example/batchDelete | 批量删除 body {ids:[]} |
| Import | POST | /api/example/import | multipart 文件导入(xls/xlsx) |
| Export | GET | /api/example/export | 导出文件 |
| Status | PUT | /api/example/status | 状态切换(有 status 列的模块) |
命名约定:结构体 `{Mod}{Action}Req / {Mod}{Action}Res`;controller 方法名 = logic 方法名 = 上表方法名。日志标题为「{模块中文名}-{动作}」(如"案例演示-查询分页记录"),动作映射:page=查询分页记录、list=查询列表、detail=查询详情、add=新增记录、update=修改记录、delete=删除记录、batchDelete=批量删除记录、import=导入数据、export=导出数据、status=设置状态。
## 后续章节地图
```text
3.2 数据库建表 → 表规范与 example 建表语句
3.3 DAO 数据层 → 四件产出与 dao.Ctx 架构红线
3.4 请求与校验 → api/v1 结构体 + gmeta + gvalid(真实代码)
3.5 逻辑层开发 → ModuleSpec 声明与方法委托(真实代码)
3.6 控制器开发 → 薄层透传(真实代码)
3.7 服务注册与路由 → service 接口三件套 + Bind 注册(真实代码)
3.8 权限节点配置 → 权限码规范与角色勾选
3.9 前端联调指引 → api/columns/querySchemas 三件套
3.10 完整代码清单 → example 模块四件套全文(摘自项目)
3.11 常见问题与排错
```
@@ -0,0 +1,92 @@
# 3.8 权限节点配置
:::tip 概述
本项目权限校验的声明在接口 gmeta 标签,落地在菜单表的权限节点(menu.type=1)。新增模块后需要确保:权限码规范正确 → 节点存在 → 角色已勾选。超管(ROLE_SUPER_ADMIN)绕过全部校验。
:::
## 权限码规范
```text
sys:{模块小驼峰}:{动作}
动作与方法名一致:page / list / detail / add / update / delete / batchDelete
/ status / import / export / resetPwd / execute / pause / resume
/ savePermission / getPermission / cache / users / save …
特例:配置网站 sys:configWeb:data|save
生成器 sys:generator:generator|batchGenerator
上传 sys:upload:file|files
```
example 模块的权限码(gmeta 声明,摘自 api/v1/example.go):
```go
permission:"sys:example:page" // Page
permission:"sys:example:list" // List
permission:"sys:example:detail" // Detail
permission:"sys:example:add" // Add
permission:"sys:example:update" // Update
permission:"sys:example:status" // Status
permission:"sys:example:delete" // Delete
permission:"sys:example:batchDelete" // BatchDelete
permission:"sys:example:import" // Import
permission:"sys:example:export" // Export
```
## 权限判定链路
```text
请求 → Bind 包装层 → consts.HasPerm(ctx, "sys:example:page")
├─ permission 标签为空 → 通过(仅登录即可)
├─ Roles 含 ROLE_SUPER_ADMIN → 通过(超管全放行)
├─ Perms 精确匹配 → 通过
└─ 否则 → code=403 信封
```
Perms 来自身份缓存(`easygoadmin:user:identity:{uid}`,TTL 300s):user → user_role → role_menu → menu 中 `type=1 且 status=0 且 hide=0` 的 permission 非空去重集合。
## 节点创建(两条途径)
### 途径一:代码生成器自动创建(推荐)
生成器 `createMenu` 副作用自动完成(幂等):
```text
主菜单:parent_id = generator.parentMenuId(默认 189)
path = /{domain}/{moduleCamel},type=0,按 path 查重已存在则跳过
权限节点(type=1,挂主菜单下):
固定 7 个:page/list/detail/add/update/delete/batchDelete
HasStatus 追加 :status;HasImport/HasExport 追加 :import/:export
```
### 途径二:菜单管理页面手工创建
「系统管理 → 菜单管理」新增:
| 字段 | 值(example 为例) |
|------|------------------|
| 上级菜单 | 选择模块主菜单 |
| 菜单类型 | 权限节点 |
| 权限标识 | sys:example:page |
| 名称 | 查询分页记录 |
| 状态 | 正常 |
## 角色勾选(生效前提)
节点创建后,**必须在「系统管理 → 角色管理 → 权限」为角色勾选**,保存走:
```text
POST /api/role/menu/save (sys:role:savePermission)
→ 重写 role_menu 关联
→ 清理该角色全部用户的身份缓存(user:identity:{uid})
```
超管不需要勾选(通配放行);前端识别 getUser 返回的 `*:*:*` 通配权限放开全部按钮。
## 开发自检清单
+ [ ] gmeta `permission` 标签已按 `sys:{module小驼峰}:{action}` 声明
+ [ ] 权限节点已在菜单存在(生成器自动 / 手工创建)
+ [ ] 测试角色已勾选新节点并重新登录(或等 300s 缓存过期)
+ [ ] 前端按钮用权限指令控制(见 [9.7 权限控制](../frontend/permission))
+ [ ] 免登录接口的 `public:"true"` 与 `middleware/public.go` 白名单一致
@@ -0,0 +1,115 @@
# 3.7 服务注册与路由
:::tip 概述
本项目没有传统的路由表文件逐条登记——路由由 Bind 反射绑定从 gmeta 注解自动生成。手工需要维护的只有两处:service 接口文件(手维护)和 router.go 的一行 Bind 注册(生成器自动写入)。
:::
## service 接口文件(手维护,真实代码摘自 internal/service/example.go)
```go
// ============================== 案例演示服务接口 ==============================
// IExample 案例演示服务接口
// 实现位于 internal/logic/example.go
type IExample interface {
// Page 案例演示分页
// 按查询条件分页返回案例演示列表
Page(ctx context.Context, req *v1.ExamplePageReq) (res *v1.ExamplePageRes, err error)
// List 案例演示平铺列表
// data 直接为数组,树形结构由前端构建
List(ctx context.Context, req *v1.ExampleListReq) (res v1.ExampleListRes, err error)
// Detail 案例演示详情
// 按主键ID查询详情
Detail(ctx context.Context, req *v1.ExampleDetailReq) (res *v1.ExampleDetailRes, err error)
// Add 新增案例演示
// 新增成功后返回新记录ID
Add(ctx context.Context, req *v1.ExampleAddReq) (res *v1.ExampleIdRes, err error)
// Update 修改案例演示
// 按主键ID更新记录
Update(ctx context.Context, req *v1.ExampleUpdateReq) (res *v1.ExampleUpdateRes, err error)
// Status 案例演示状态
// 设置记录的状态字段
Status(ctx context.Context, req *v1.ExampleStatusReq) (res *v1.ExampleStatusRes, err error)
// Delete 删除案例演示
// 按主键ID删除单条记录
Delete(ctx context.Context, req *v1.ExampleDeleteReq) (res *v1.ExampleDeleteRes, err error)
// BatchDelete 批量删除案例演示
// 按主键ID列表批量删除记录
BatchDelete(ctx context.Context, req *v1.ExampleBatchDeleteReq) (res *v1.ExampleBatchDeleteRes, err error)
// Import 导入案例演示
// 接收上传的 xls/xlsx 文件并批量导入数据
Import(ctx context.Context, req *v1.ExampleImportReq) (res *v1.ExampleImportRes, err error)
// Export 导出案例演示
// 按查询条件导出数据并返回文件访问地址
Export(ctx context.Context, req *v1.ExampleExportReq) (res *v1.ExampleExportRes, err error)
}
// localExample 案例演示服务实现实例
var localExample IExample
// Example 获取案例演示服务实例
func Example() IExample {
if localExample == nil {
panic("implement not found for interface IExample, forgot register?")
}
return localExample
}
// RegisterExample 注册案例演示服务实现
func RegisterExample(i IExample) {
localExample = i
}
```
三件套缺一不可:**接口 IXxx**(controller 依赖)+ **取用函数 Xxx()**(controller 调用入口)+ **RegisterXxx()**(logic init 注册)。未注册时取用会 panic(启动即暴露配置错误,而非运行时空指针)。
::: warning 禁止 gf gen service
service 接口文件是手工重组维护的(每模块一文件、中文注释齐全)。**禁止整体运行 `gf gen service`**——会按域重新生成并覆盖手工结构。logic 方法签名变更时手工同步接口文件。
:::
## 路由注册(internal/router/router.go)
```go
// 按域顺序注册(摘自 registerControllers)
Bind(s, new(controller.CAuth)) // 认证
Bind(s, new(controller.CIndex)) // 个人中心
Bind(s, new(controller.CUser)) // system 域
Bind(s, new(controller.CRole))
// …
Bind(s, new(controller.CExample)) // 代码生成器注册
```
新增模块时在最后一个 `Bind` 行后追加一行(代码生成器 `registerRouter` 副作用自动写入,已存在则跳过,幂等)。
## Bind 反射绑定做了什么(internal/router/bind.go)
```text
Bind(s, new(controller.CXxx)) 对每个导出方法:
1. 校验签名:func (c *Xxx) Page(ctx, req *v1.XxxReq) (*v1.XxxRes, error)
2. 解析请求结构体内嵌 gmeta.Meta 扩展标签:
method / path / summary / permission / log / logType / public
3. 按 method:path 注册路由 + 写入路由元数据表 meta.Register
4. 补注册进 gf goai(闭包路由 gf 不会自动收集)→ /api.json 原生 OpenAPI
5. 生成统一包装 handler:
元数据入 ctx → 权限校验(403 短路)→ r.Parse 参数解析(含 gvalid)
→ 调用业务方法 → response.Ok/Fail 信封写出
```
::: tip 注解即事实
权限校验与操作日志共用同一份路由元数据(meta 表),gmeta 标签是唯一真相。不存在"代码改了忘改路由表/权限表"的可能。
:::
## URL 约定
+ 全部路由统一 `/api` 前缀,**URL 中无 `/v1` 版本段**(`api/v1` 只是 Go 包名)。例:`GET /api/user/page`。
+ 公开白名单(`middleware/public.go`,精确匹配):`/api/login`、`/api/register`(白名单放行,路由未注册)、`/api/captcha`、`/api/oauth2/token`、`/api/health`;另放行 `/api.json` 与 `/swagger*`。
+ **三处必须一致**:public.go 白名单 / gmeta `public:"true"` 标注 / router.go 手工注册。
## 注册验证
```sh
go build ./... && ./bin/admin-server.exe
# 启动后检查:
curl http://127.0.0.1:8000/api.json | grep "/api/example" # OpenAPI 已含新路由
# 或配置 server.dumpRouterMap: true 启动看路由表
```
@@ -0,0 +1,128 @@
# 3.4 请求与校验
:::tip 概述
本项目没有独立的"验证器文件"——请求校验由 `api/v1` 请求结构体上的 gvalid 标签完成,路由/权限/日志元数据由内嵌 gmeta.Meta 声明。一个结构体即接口契约的全部。
:::
## 请求结构体解剖(真实代码,摘自 api/v1/example.go)
```go
// ============================== 案例演示模块(goframe_example) ==============================
// ExamplePageReq 案例演示分页请求
type ExamplePageReq struct {
gmeta.Meta `method:"get" path:"/api/example/page" summary:"案例演示分页" permission:"sys:example:page" log:"案例演示-查询分页记录" logType:"query"`
PageReq
Name string `json:"name" dc:"案例名称"` // 案例名称
Type int `json:"type" dc:"案例类型"` // 案例类型
Status int `json:"status" dc:"案例状态"` // 案例状态
Keyword string `json:"keyword" dc:"关键词"` // 关键词
}
// ExampleDetailReq 案例演示详情请求
type ExampleDetailReq struct {
gmeta.Meta `method:"get" path:"/api/example/detail/:id" summary:"案例演示详情" permission:"sys:example:detail" log:"案例演示-查询详情" logType:"query"`
Id int64 `json:"id" v:"required#缺少ID" dc:"记录ID"` // 记录ID
}
// ExampleAddReq 新增案例演示请求
type ExampleAddReq struct {
gmeta.Meta `method:"post" path:"/api/example/add" summary:"新增案例演示" permission:"sys:example:add" log:"案例演示-新增记录" logType:"add"`
Name string `json:"name" v:"required#请输入案例名称" dc:"案例名称"` // 案例名称
Avatar string `json:"avatar" dc:"案例图片"` // 案例图片
Type int `json:"type" dc:"案例类型"` // 案例类型
Status int `json:"status" dc:"案例状态"` // 案例状态
Sort int `json:"sort" dc:"案例排序"` // 案例排序
}
```
## 标签体系一览
| 标签 | 来源 | 作用 |
|------|------|------|
| `gmeta.Meta` | gf 框架 | 内嵌后由本项目 bind.go 扩展解析 |
| `method/path/summary` | gmeta | HTTP 方法、路由模板(`:id` 路由参数)、OpenAPI 摘要 |
| `permission` | gmeta | 权限节点 `sys:{module小驼峰}:{action}`;空 = 登录即可 |
| `log/logType` | gmeta | 操作日志标题与类型(query/add/update/delete/import/export…) |
| `public` | gmeta | `"true"` = 免登录(须与 middleware/public.go 白名单一致) |
| `json` | 标准 | 出入参字段名,**一律驼峰**(例外仅登录/令牌接口的下划线字段) |
| `v` | gvalid | 校验规则,如 `required#请输入案例名称`(# 后为自定义错误文案) |
| `dc` | goai | OpenAPI 文档中的字段说明 |
| `p` | ghttp | multipart 上传字段名(如 `p:"file"`) |
## gvalid 常用规则
```go
Id int64 `json:"id" v:"required#缺少ID"` // 必填
Age int `json:"age" v:"min:0#年龄不能为负"` // 数值范围
Ids []int64 `json:"ids" v:"required#缺少ID列表"` // 数组必填
Email string `json:"email" v:"email#邮箱格式不正确"` // 格式
Status int `json:"status" v:"in:1,2#状态值不合法"` // 枚举(如角色 dataScope 用 in:1,2,3,4)
```
校验失败由框架返回 code=400 信封,错误文案取 `#` 后缀;无需在业务代码里手写 if 判断。
## 路由参数承接
gmeta path 中的 `:id` 由请求结构体同名(驼峰 json)字段承接:
```text
路由模板 GET /api/example/detail/:id
└─▶ ExampleDetailReq.Id int64 `json:"id"` ← r.Parse 自动绑定
```
## 请求解析流程(Bind 包装层完成)
```text
1. r.Parse(req):路由参数 + query + body 一次性绑定(含 gvalid 校验)
2. 实现 BodyUnmarshaler 接口的请求对象走自定义解析逃生舱
(请求体顶层形态无法用结构体标签表达时使用,gvalid 需在实现内自完成)
3. 校验失败 → response.Fail(code=400) 短路返回,业务方法不执行
```
## 响应结构体(真实代码,摘自 api/v1/example.go)
```go
// ExamplePageRes 案例演示分页结果
// 统一壳:records/total/size/current/pages
type ExamplePageRes = PageData[ExampleItem]
// ExampleListRes 案例演示平铺列表结果
// data 直接为数组,树形结构由前端 buildTree 构建
type ExampleListRes = []ExampleItem
// ExampleIdRes 新增返回ID结构
type ExampleIdRes struct {
Id int64 `json:"id"` // 主键ID
}
// ExampleUpdateRes 修改案例演示结果
type ExampleUpdateRes struct{}
```
要点:
+ 分页结果统一用泛型壳 `PageData[T]`(`v1.NewPageData` 构造)
+ 新增返回统一 `{id}` 结构(模块自定义 `XxxIdRes`;公共场景用 `common.IdRes`)
+ 修改/删除等空结果返回空结构体 `struct{}`
+ 行结构体含 `{Field}Text string` 字段时由基座自动填字典显示名
## 行结构体示例(含字典文本字段)
```go
// ExampleItem 案例演示行数据
// 用于分页列表等场景的基础信息
type ExampleItem struct {
Id int `json:"id"` // 主键ID
Name string `json:"name"` // 案例名称
Avatar string `json:"avatar"` // 案例图片
Type int `json:"type"` // 案例类型
Status int `json:"status"` // 案例状态
Sort int `json:"sort"` // 案例排序
CreateUser string `json:"createUser"` // 创建人
CreateTime *gtime.Time `json:"createTime"` // 创建时间
StatusText string `json:"statusText"` // 状态显示名(dict_item 解析)
}
```
`StatusText` 与 `Status` 配对,配合 logic 里 `SerializeMaps = {"status": "example_status"}` 声明,读取时自动填充(见 3.5 逻辑层开发)。