签入版本

This commit is contained in:
小蚂蚁云
2026-10-25 17:24:27 +08:00
parent 48911cc08f
commit fdbeae092a
1010 changed files with 30682 additions and 95274 deletions
+49 -45
View File
@@ -1,59 +1,63 @@
# 3.10 完整代码清单
# 3.11 完整代码清单
:::tip 概述
本节汇总 example(案例演示)模块涉及的所有文件,方便开发时快速定位。
本页汇总 example 模块的全部文件与职责,作为手工开发的核对清单。各文件完整实现见本章对应小节。
:::
## 后端文件清单
## 文件总览
| 文件路径 | 说明 | 核心内容 |
|----------|------|----------|
| `app/model/Example.php` | 数据模型 | 继承 BaseModel,声明表名 `example` |
| `app/validate/ExampleValidate.php` | 验证器 | name 字段必填,add/update 场景 |
| `app/logic/ExampleLogic.php` | 业务逻辑 | 属性配置 + import/export 方法 |
| `app/controller/ExampleController.php` | 控制器 | 9 个接口(page/list/detail/add/update/delete/batchDelete/import/export) |
| `route/app.php` | 路由 | 第 371~383 行,9 条路由 |
| 文件 | 层 | 核心内容 |
|---|---|---|
| 数据库 `gin_example` | — | 11 列(含审计四件套 + del_flag),3 个普通索引 |
| `internal/model/dto/example.go` | 契约 | ExampleItem / PageReq / ListReq / DetailReq / AddReq / UpdateReq / StatusReq / DeleteReq / BatchDeleteReq / ImportReq+UnmarshalBody / ImportRes+ResMsg / ExportReq / ExportRes |
| `internal/repository/example.go` | 数据访问 | ExampleRepo + NewExampleRepo(表名 gin_example)+ Ctx |
| `internal/service/example.go` | 业务 | sExample 三段式;exampleSpec(ModuleSpec 声明);exampleToData;标准七方法 + Status/Import/Export 手写 |
| `internal/handler/example.go` | 接口 | CExample + 十个委托方法 |
| `internal/router/router.go` | 路由 | CExample 的 RouteSpec 表(10 条声明) |
| `ui/src/api/tool/example.ts` | 前端 API | 与十个接口对应的请求函数 |
| `ui/src/views/tool/example/` | 前端页面 | 列表 + 编辑 |
## 前端文件清单
## 接口一览(RouteSpec 派生)
| 文件路径 | 说明 |
|----------|------|
| `ui/src/api/tool/example.ts` | API 接口模块(9 个函数) |
| `ui/src/views/tool/example/index.vue` | 列表页 |
| `ui/src/views/tool/example/edit.vue` | 新增/编辑弹窗 |
| `ui/src/views/tool/example/detail.vue` | 详情弹窗 |
| `ui/src/views/tool/example/columns.ts` | 表格列配置 |
| `ui/src/views/tool/example/querySchemas.ts` | 查询条件配置 |
| 方法 | 路径 | 权限码 | 日志类型 |
|---|---|---|---|
| Page | GET /api/example/page | sys:example:page | query |
| List | GET /api/example/list | sys:example:list | query |
| Detail | GET /api/example/detail/:id | sys:example:detail | query |
| Add | POST /api/example/add | sys:example:add | add |
| Update | PUT /api/example/update | sys:example:update | update |
| Status | PUT /api/example/status | sys:example:status | update |
| Delete | DELETE /api/example/delete/:id | sys:example:delete | delete |
| BatchDelete | DELETE /api/example/batchDelete | sys:example:batchDelete | delete |
| Import | POST /api/example/import | sys:example:import | import |
| Export | GET /api/example/export | sys:example:export | export |
## 数据库
## 能力与实现对照
| 对象 | 说明 |
|------|------|
| `think_example` 表 | 案例演示数据表 |
| 能力 | 实现位置 |
|---|---|
| name 模糊 / type、status 等值过滤 | exampleSpec.LikeFields / EqFields |
| status → statusText 字典回填 | exampleSpec.SerializeMaps |
| avatar 写侧转正、读侧补域名 | exampleSpec.FileFields + FileSaveDir |
| 全量表单语义更新 | ToData + 全局 Update 钩子盖章 |
| 软删除 | 基座 Delete/BatchDelete(del_flag=1) |
| 单列状态更新 | 手写 Status |
| 导入逐行校验 | 手写 Import + UnmarshalBody |
| 导出过滤复用 + 字典翻译 | 手写 Export(applyFilters + DictGetText) |
| 权限/日志 | RouteSpec.Permission / Log 声明,中间件执行 |
## 权限节点
## 自检
| 权限码 | 说明 |
|--------|------|
| `sys:example:page` | 案例演示分页列表 |
| `sys:example:add` | 添加案例演示 |
| `sys:example:update` | 修改案例演示 |
| `sys:example:delete` | 删除案例演示 |
| `sys:example:import` | 导入案例演示 |
| `sys:example:export` | 导出案例演示 |
完成后逐项核对:
## 字典数据
- [ ] 十个接口全部可访问,权限码与菜单节点一致
- [ ] 分页返回 `PageData` 五字段(records/total/size/current/pages)
- [ ] statusText 有值(字典 example_status 已配置项)
- [ ] avatar 上传后转正到 `upload/example/yyyymmdd/`,读侧返回完整 URL
- [ ] 导入失败行有逐行原因,ResMsg 提示语正确
- [ ] 导出返回可下载的文件 URL
- [ ] 删除后列表不可见(软删),数据库 del_flag=1
| 字典编码 | 说明 | 字典项 |
|---------|------|--------|
| `example_type` | 案例类型 | 按实际配置 |
| `example_status` | 案例状态 | 0=禁用, 1=启用 |
## 下一步
## 代码生成器替代方案
example 模块本身就是代码生成器的参考模板。如果需要生成类似模块:
1. 建好数据库表
2. 访问代码生成器页面
3. 选择表,点击生成
4. 自动创建 Controller / Logic / Model / Validate / 前端文件
→ [3.12 常见问题与排错](/zh/guide/faq.html)
@@ -1,305 +0,0 @@
# 3.6 控制器开发
:::tip 概述
控制器是 HTTP 请求的入口,负责参数获取、调用 Logic 层、返回 JSON 响应。通过 `#[Permission]` 和 `#[Log]` 注解实现权限校验和操作日志。
:::
## 控制器文件位置
```text
app/controller/ExampleController.php
```
## 完整代码(来自项目实际代码)
```php
<?php
declare(strict_types=1);
namespace app\controller;
use app\BaseController; // 控制器基类
use app\logic\ExampleLogic; // 案例演示业务逻辑
use app\attribute\Log; // 操作日志注解
use app\attribute\Permission; // 权限校验注解
use think\response\Json; // JSON 响应类型
/**
* 案例演示管理控制器
*
* 负责案例演示相关的 HTTP 接口入口,包括:
* - 分页列表、列表查询、详情查询;
* - 新增、修改、删除、批量删除;
* - 导入、导出。
*
* 具体业务逻辑委托给 ExampleLogic 处理,
* 并通过注解统一实现日志记录与权限校验。
*/
class ExampleController extends BaseController
{
/**
* 案例演示业务逻辑层
*
* @var ExampleLogic
*/
protected ExampleLogic $logic;
/**
* 控制器初始化
*
* 在每次请求进入控制器方法前执行,用于实例化业务逻辑层。
* ThinkPHP 会自动调用此方法(在构造函数之后)。
*
* @return void
*/
protected function initialize(): void
{
$this->logic = new ExampleLogic();
}
// ... 以下为各接口方法
}
```
:::tip 控制器模式
每个控制器遵循固定模式:
1. `initialize()` 中实例化 Logic
2. 每个方法标注 `#[Log]` 和 `#[Permission]` 注解
3. 简单调用 `parent::_index()` / `_detail()` / `_add()` 等通用方法
4. 复杂操作(如导入导出)自行处理并 try-catch
:::
```php
/**
* 案例演示分页列表
*
* GET /example/page
* 对应权限:sys:example:page
*
* 调用 BaseController::_index(),内部流程:
* 1. 合并分页参数(pageNo/pageSize)和普通参数
* 2. 调用 ExampleLogic::pageList()
* 3. 返回标准分页响应
*
* @return Json
*/
#[Log('案例演示-查询分页记录', Log::TYPE_QUERY)]
#[Permission('sys:example:page', '案例演示分页')]
public function page(): Json
{
return parent::_index($this->logic);
}
/**
* 案例演示列表
*
* GET /example/list
* 返回全量数据(不分页),适用于下拉选择等场景。
*
* @return Json
*/
#[Log('案例演示-查询列表', Log::TYPE_QUERY)]
public function list(): Json
{
return parent::_list($this->logic);
}
/**
* 案例演示详情
*
* GET /example/detail/{id}
*
* 调用 BaseController::_detail(),内部流程:
* 1. 调用 ExampleLogic::detail($id)
* 2. 查询为空返回 404
* 3. 自动补全文件字段域名、枚举显示名
* 4. 触发 afterDetail 钩子
*
* @param int $id 案例演示ID
* @return Json
*/
#[Log('案例演示-查询详情', Log::TYPE_QUERY, '查询案例演示ID:{id}')]
public function detail(int $id): Json
{
return parent::_detail($this->logic, $id);
}
/**
* 添加案例演示
*
* POST /example/add
* 对应权限:sys:example:add
*
* 调用 BaseController::_add(),内部流程:
* 1. 调用 ExampleLogic::add($data)
* 2. 触发 beforeAdd/afterAdd 钩子
* 3. 返回新记录 ID
*
* @return Json
*/
#[Log('案例演示-新增记录', Log::TYPE_ADD, '新增案例演示:{name}')]
#[Permission('sys:example:add', '添加案例演示')]
public function add(): Json
{
// getJsonBody() 获取前端 POST 提交的 JSON 数据
return parent::_add($this->logic, $this->getJsonBody());
}
/**
* 更新案例演示
*
* PUT /example/update
* 对应权限:sys:example:update
*
* 从 JSON 请求体中读取 id,若缺失则直接返回失败。
*
* @return Json
*/
#[Log('案例演示-修改记录', Log::TYPE_UPDATE, '修改案例演示ID:{id}')]
#[Permission('sys:example:update', '修改案例演示')]
public function update(): Json
{
$data = $this->getJsonBody();
$id = $data['id'] ?? 0;
// 校验 ID 参数
if (empty($id)) {
return $this->fail('缺少id参数');
}
return parent::_edit($this->logic, $id, $data);
}
/**
* 删除案例演示
*
* DELETE /example/delete/{id}
* 对应权限:sys:example:delete
*
* 调用 BaseController::_remove(),内部流程:
* 1. 调用 ExampleLogic::delete($id)
* 2. 触发 beforeDelete/afterDelete 钩子
* 3. 设置 is_delete=1(软删除)
*
* @param int $id 案例演示ID
* @return Json
*/
#[Log('案例演示-删除记录', Log::TYPE_DELETE, '删除案例演示ID:{id}')]
#[Permission('sys:example:delete', '删除案例演示')]
public function delete(int $id): Json
{
return parent::_remove($this->logic, $id);
}
/**
* 批量删除案例演示
*
* DELETE /example/batchDelete
* 对应权限:sys:example:delete
*
* 从 JSON 请求体中读取 ids 数组。
*
* @return Json
*/
#[Log('案例演示-批量删除记录', Log::TYPE_DELETE)]
#[Permission('sys:example:delete', '删除案例演示')]
public function batchDelete(): Json
{
$ids = $this->getJsonBody()['ids'] ?? [];
return parent::_batchRemove($this->logic, $ids);
}
/**
* 导入案例演示
*
* POST /example/import
* 对应权限:sys:example:import
*
* 处理流程:
* 1. 从请求中获取上传文件
* 2. 校验文件扩展名(仅 xls、xlsx)
* 3. 调用 ExampleLogic::import() 解析并导入
*
* @return Json
*/
#[Log('案例演示-导入数据', Log::TYPE_IMPORT)]
#[Permission('sys:example:import', '导入案例演示')]
public function import(): Json
{
$file = $this->request->file('file');
if (!$file) {
return $this->fail('请选择文件');
}
$ext = strtolower($file->getOriginalExtension());
if (!in_array($ext, ['xls', 'xlsx'])) {
return $this->fail('仅支持 xls、xlsx 格式的文件');
}
try {
$result = $this->logic->import($file->getRealPath());
$msg = empty($result['errors'])
? "成功导入 {$result['count']} 条数据"
: "成功导入 {$result['count']} 条,失败 " . count($result['errors']) . " 条";
return $this->success($result, $msg);
} catch (\Exception $e) {
return $this->fail('导入失败: ' . $e->getMessage());
}
}
/**
* 导出案例演示
*
* GET /example/export
* 对应权限:sys:example:export
*
* @return Json
*/
#[Log('案例演示-导出数据', Log::TYPE_EXPORT)]
#[Permission('sys:example:export', '导出案例演示')]
public function export(): Json
{
try {
$filePath = $this->logic->export($this->getParams());
return $this->success($filePath, '导出成功');
} catch (\Exception $e) {
return $this->fail('导出失败: ' . $e->getMessage());
}
}
}
```
## 注解说明
### #[Log] 操作日志注解
```php
#[Log('案例演示-新增记录', Log::TYPE_ADD, '新增案例演示:{name}')]
// │ │ │
// │ │ └── 描述(支持 {param} 占位符)
// │ └── 操作类型
// └── 操作标题
```
### #[Permission] 权限注解
```php
#[Permission('sys:example:add', '添加案例演示')]
// │ │
// │ └── 权限名称
// └── 权限编码
```
## BaseController 通用方法
| 方法 | 用途 | 内部调用 |
|------|------|----------|
| `_index($logic)` | 分页查询 | `$logic->pageList()` |
| `_list($logic)` | 全量列表 | `$logic->allList()` |
| `_detail($logic, $id)` | 详情查询 | `$logic->detail($id)` |
| `_add($logic, $data)` | 新增 | `$logic->add($data)` |
| `_edit($logic, $id, $data)` | 修改 | `$logic->update($id, $data)` |
| `_remove($logic, $id)` | 删除 | `$logic->delete($id)` |
| `_batchRemove($logic, $ids)` | 批量删除 | `$logic->batchDelete($ids)` |
+49 -58
View File
@@ -1,76 +1,67 @@
# 3.2 数据库建表
:::tip 概述
建表是模块开发的第一步。所有表必须包含公共字段(id、软删除、审计字段),这是 BaseModel 自动处理的基础。
表是模块开发的起点。本页以 `gin_example` 为例说明建表规约:命名、公共审计字段、索引与租户列的取舍。
:::
## 公共字段规范
所有业务表必须包含以下公共字段:
## 示例表结构
```sql
-- 公共字段(每个表都必须包含)
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
is_delete TINYINT(1) DEFAULT 0 COMMENT '软删除 0=正常 1=已删除',
create_user VARCHAR(50) DEFAULT '' COMMENT '创建人',
create_time DATETIME COMMENT '创建时间',
update_user VARCHAR(50) DEFAULT '' COMMENT '更新人',
update_time DATETIME COMMENT '更新时间'
-- internal/repository/example.go 对应表
CREATE TABLE `gin_example` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(255) NOT NULL COMMENT '案例名称',
`avatar` varchar(255) NULL DEFAULT NULL COMMENT '案例图片',
`type` int NULL DEFAULT 0 COMMENT '案例类型:1-类型1 2-类型2 3-类型3 4-类型4',
`status` int NULL DEFAULT 0 COMMENT '案例状态:1-在用 2-停用',
`sort` int NULL DEFAULT 0 COMMENT '案例排序',
`create_user` varchar(50) NULL DEFAULT NULL COMMENT '创建人',
`create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_user` varchar(50) NULL DEFAULT NULL COMMENT '更新人',
`update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
`del_flag` int NULL DEFAULT 0 COMMENT '删除标识:0-正常 1-已删除',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_gin_example_del_flag`(`del_flag` ASC) USING BTREE,
INDEX `idx_gin_example_name`(`name` ASC) USING BTREE,
INDEX `idx_gin_example_status`(`status` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '案例演示表';
```
:::warning 为什么必须包含这些字段?
- `is_delete`:BaseModel 的全局查询范围 `soft_delete` 会自动过滤 `is_delete=1` 的数据
- `create_user` / `create_time`:BaseModel 的 `onBeforeInsert` 事件自动填充
- `update_user` / `update_time`:BaseModel 的 `onBeforeUpdate` 事件自动填充
:::
## 公共审计字段
## 建表 SQL 示例(example 模块)
所有业务表统一带以下字段,**业务代码不手工赋值**——全局 Create/Update 钩子自动补齐:
```sql
CREATE TABLE think_example (
-- 主键
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
| 字段 | 补齐方 | 说明 |
|---|---|---|
| create_user / create_time | Create 钩子 | 创建人 / 创建时间 |
| update_user / update_time | Update 钩子 | 更新人 / 更新时间 |
| del_flag | Create 钩子 | 默认 0;删除一律置 1 |
| tenant_id | Create 钩子 | 仅租户级表需要 |
-- 业务字段
name VARCHAR(200) NOT NULL COMMENT '案例名称',
type TINYINT(1) DEFAULT 0 COMMENT '案例类型(字典 example_type)',
status TINYINT(1) DEFAULT 0 COMMENT '案例状态(字典 example_status)',
sort INT DEFAULT 0 COMMENT '排序值(越小越靠前)',
avatar VARCHAR(500) DEFAULT '' COMMENT '头像(文件路径)',
## 租户列取舍
-- 公共字段(必须)
is_delete TINYINT(1) DEFAULT 0 COMMENT '软删除 0=正常 1=已删除',
create_user VARCHAR(50) DEFAULT '' COMMENT '创建人',
create_time DATETIME COMMENT '创建时间',
update_user VARCHAR(50) DEFAULT '' COMMENT '更新人',
update_time DATETIME COMMENT '更新时间',
| 表类型 | 是否带 tenant_id | 示例 |
|---|---|---|
| 租户级表 | 带,且必须在 hooks 租户白名单内(共 9 张:user/role/dept/level/position/article/category/notice/link) | gin_article |
| 平台级表 | 不带 | gin_example、gin_dict、gin_param |
-- 索引
INDEX idx_type (type),
INDEX idx_status (status),
INDEX idx_sort (sort)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='案例演示表';
```
不带 tenant_id 的表不做租户隔离过滤,适合全局配置、字典、示例类数据。新表若需租户隔离,需同步把表名加入 `internal/model/hooks` 的租户白名单——两处不一致会导致隔离失效。
## 字段与 Logic 属性对应关系
## 建表要点
| 数据库字段 | 类型 | Logic 属性 | 说明 |
|-----------|------|-----------|------|
| `name` | VARCHAR(200) | `pageLikeFields` | LIKE 模糊搜索 |
| `type` | TINYINT(1) | `pageEqFields` + `serializeMaps` | 精确匹配 + 枚举翻译 |
| `status` | TINYINT(1) | `pageEqFields` + `serializeMaps` | 精确匹配 + 枚举翻译 |
| `sort` | INT | `pageOrderBy` | 默认排序字段 |
| `avatar` | VARCHAR(500) | `fileFields` | 文件字段,自动处理 |
| 要点 | 说明 |
|---|---|
| 主键 | 统一 `id` INT 自增,不用其他类型主键 |
| 字符集 | utf8mb4(兼容 emoji 与生僻字) |
| 索引 | 查询高频列建普通索引;命名 `idx_{表}_{列}`;唯一约束命名 `uk_{表}_{列}` |
| 注释 | 每列必须有 COMMENT;枚举列在注释中列出取值与含义(如 `1-在用 2-停用`),生成器与文档依赖它 |
| 软删 | 不建外键(软删除 + 应用层维护引用完整性) |
| 时间 | datetime;写入由钩子负责,查询无需关心时区(应用统一 Asia/Shanghai) |
## 常见字段类型参考
## 枚举列与字典
| 用途 | 推荐类型 | 示例 |
|------|----------|------|
| 主键 | `INT AUTO_INCREMENT` | `id INT PRIMARY KEY AUTO_INCREMENT` |
| 外键 | `INT DEFAULT 0` | `category_id INT DEFAULT 0` |
| 短文本 | `VARCHAR(n)` | `name VARCHAR(200)` |
| 长文本 | `TEXT` | `content TEXT` |
| 布尔值 | `TINYINT(1)` | `status TINYINT(1) DEFAULT 0` |
| 整数 | `INT` | `sort INT DEFAULT 0` |
| 日期时间 | `DATETIME` | `create_time DATETIME` |
| 文件路径 | `VARCHAR(500)` | `avatar VARCHAR(500)` |
示例表的 `type`/`status` 是整型枚举列,显示名由**系统字典**维护(如 `example_status`),读侧经 `SerializeMaps` 声明自动回填 `{field}Text` 字段。建表时只需保证注释中的取值与字典项一致,见 [3.5 业务层开发(service)](/zh/guide/service.html)。
## 下一步
→ [3.3 契约层开发(dto)](/zh/guide/dto.html)
+121
View File
@@ -0,0 +1,121 @@
# 3.3 契约层开发(dto)
:::tip 概述
dto 是模块的对外契约:json 标签即 API 字段名,binding/msg 标签给出校验规则与提示语,form 标签供 GET 参数绑定。契约一旦发布应保持稳定——前端与生成器都依赖它。
:::
## 文件位置与命名
```text
internal/model/dto/example.go (package dto,一模块一文件)
```
结构体按职责命名:`ExampleItem`(行结构)、`ExamplePageReq`(分页请求)、`ExampleDetailRes`(详情响应)……
## 行结构与类型别名
```go
// internal/model/dto/example.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"` // 案例状态(取值以字典 example_status 为准)
Sort int `json:"sort"` // 案例排序
CreateUser string `json:"createUser"` // 创建人
CreateTime *LocalTime `json:"createTime"` // 创建时间(*LocalTime,NULL 输出 null)
StatusText string `json:"statusText"` // 状态显示名(按字典 example_status 回填)
}
```
结果类型用别名收敛:
```go
type ExamplePageRes = PageData[ExampleItem] // 分页壳
type ExampleListRes = []ExampleItem // 平铺列表
type ExampleExportRes = string // 导出结果直接是文件 URL
```
## 请求结构与标签
```go
// internal/model/dto/example.go
type ExamplePageReq struct {
PageReq // 分页基础参数(内嵌,pageNo/pageSize 即刻可用)
Name string `json:"name" form:"name" dc:"案例名称"` // 模糊匹配,空串跳过
Type int `json:"type" form:"type" dc:"案例类型"` // 等值,零值跳过
Status int `json:"status" form:"status" dc:"案例状态"` // 等值,零值跳过
Keyword string `json:"keyword" form:"keyword" dc:"关键词"` // 多列 OR 关键词
}
type ExampleDetailReq struct {
Id int64 `json:"id" binding:"required" msg:"缺少ID" dc:"记录ID"`
}
```
| 标签 | 作用 |
|---|---|
| json | 对外 API 字段名(小驼峰) |
| form | GET/query 参数绑定名(与 json 同名) |
| binding | 校验规则(required / min / max / oneof 等) |
| msg | 校验失败提示语 |
| dc | 字段说明(供 Swagger/OpenAPI 文档生成) |
## 命名与语义规约
| 请求/响应 | 语义 |
|---|---|
| AddReq | **全量必填**:新增时业务字段都要给(required 标注) |
| UpdateReq | **全量表单语义**:提交全部业务字段,未改字段也回传原值,零值照样落库 |
| DeleteReq | 单条删除,`Id` required |
| BatchDeleteReq | `Ids []int64` + `binding:"min=1"` |
| StatusReq | 单列状态更新入口,只提交 ID 与目标状态 |
时间字段统一 `*time.Time`(模块内用 `*LocalTime` 包装输出格式);文件字段用 `*multipart.FileHeader`。
## 文件上传字段与解析逃生舱
```go
// internal/model/dto/example.go
type ExampleImportReq struct {
File *multipart.FileHeader `form:"file" binding:"required" msg:"请选择文件" dc:"xls/xlsx 文件"`
}
// UnmarshalBody 实现 multipart 表单解析逃生舱(跳过默认 JSON 解析)
func (r *ExampleImportReq) UnmarshalBody(c *gin.Context) error {
f, err := c.FormFile("file")
if err != nil {
return errors.New("请选择文件")
}
r.File = f
return nil
}
```
任何结构体只要实现 `UnmarshalBody(c *gin.Context) error` 接口,绑定引擎就跳过默认解析,完全交给结构体自己处理(文件上传的唯一正确姿势)。
## 响应侧可选能力
```go
// ResMsg 实现 response.MsgProvider:成功通道直接弹出该提示语
func (r *ExampleImportRes) ResMsg() string {
if r.Failed > 0 {
return fmt.Sprintf("成功导入 %d 条,失败 %d 条", r.Success, r.Failed)
}
return fmt.Sprintf("成功导入 %d 条数据", r.Success)
}
```
## 编写检查清单
- [ ] 每个字段都有 json + form 标签(form 与 json 同名)
- [ ] 请求字段有 dc 说明;校验字段有 binding + msg
- [ ] 时间用 `*LocalTime`,批量删除用 `Ids []int64`
- [ ] AddReq 全量 required,UpdateReq 全量不带 required 之外的过滤语义
- [ ] 文件字段实现 UnmarshalBody
## 下一步
→ [3.4 数据访问层(repository)](/zh/guide/repository.html)
+38 -68
View File
@@ -1,93 +1,63 @@
# 3.11 常见问题与排错
# 3.12 常见问题与排错
## 后端问题
:::tip 概述
本页汇总模块开发高频问题。核心口诀:**新接口只加 RouteSpec 声明 + handler 方法,数据访问只走 Ctx,业务错误原样上抛。**
:::
### Q: 新增接口返回 401
## 路由与注册
A: 检查以下几点:
1. 请求头是否携带 `Authorization: Bearer <token>`
2. Token 是否过期(默认 2 小时)
3. 路由是否在 `route/app.php` 的需认证组中
**Q:加了 handler 方法但接口 404?**
### Q: 新增接口返回 403
方法签名不匹配。必须是 `func (c *CXxx) Method(ctx context.Context, req *dto.XxxReq) (*dto.XxxRes, error)`,且 RouteSpec 表中有同名键。反射扫描按签名识别,不匹配的方法静默跳过。
A: 权限不足,检查:
1. 是否在 Controller 方法上添加了 `#[Permission]` 注解
2. 权限码是否在菜单管理中配置
3. 用户角色是否包含该权限节点
**Q:改了 router.go 需要重启吗?**
### Q: 枚举显示名(typeText/statusText)为空
需要。路由注册发生在启动时,改动后重启服务(`make run`)。
A: 检查:
1. 字典表中是否存在对应的字典编码(如 `example_type`)
2. 字典项的 value 值是否与数据中的值匹配
3. Logic 中 `serializeMaps` 配置是否正确
**Q:新模块忘记在 registerControllers 加 Bind?**
```php
// ExampleLogic 中的配置
所有控制器必须在 `internal/router/router.go` 的 `registerControllers` 中有一条 `Bind` 声明,否则整个控制器不注册。代码生成器会自动追加。
/**
* 枚举显示名映射
*
* @var array
*/
protected array $serializeMaps = [
'type' => 'example_type',
'status' => 'example_status',
];
```
## 数据访问
### Q: 文件上传后 avatar 路径为空
**Q:查询能搜到已删除的数据?**
A: 检查:
1. `.env` 中 `FILE.UPLOAD_DIR` 是否配置
2. 上传目录是否存在且有写入权限
3. Logic 中是否配置了 `fileFields`
说明绕过了 `Ctx`。检查是否用了 `Raw()` 或裸 `*gorm.DB`——一切查询走 `NewXxxRepo().Ctx(ctx)`。
```php
// ExampleLogic 中的配置
protected array $fileFields = ['avatar'];
protected string $fileSaveDir = 'example';
```
**Q:租户 A 看到了租户 B 的数据?**
### Q: 排序不生效
两个检查点:表是否在 `internal/model/hooks` 的租户白名单中(必须含 tenant_id 列);写入时 Create 钩子是否正常补齐 tenant_id。
A: 检查 Logic 中 `pageOrderBy` 配置:
**Q:物理删除报错"被拦截"?**
```php
// ExampleLogic 中的配置
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
```
设计如此。业务删除用 `SoftDelete`;定时清理等场景用 `hooks.WithPhysicalDelete(ctx)` 显式放行。
前端可通过 `orderField` 和 `orderType` 参数覆盖默认排序。
## 业务层
## 前端问题
**Q:Update 后某些字段变回零值?**
### Q: 菜单不显示
Update 是全量表单语义:未提交的字段按零值落库。前端必须回传全部业务字段(未修改的传原值)。
A: 检查:
1. 后端菜单管理中是否添加了对应菜单
2. 菜单的组件路径是否正确(如 `tool/example/index`)
3. 前端 `views` 目录下是否有对应的 `.vue` 文件
4. 用户角色是否包含该菜单权限
**Q:字典字段没有 statusText?**
### Q: API 请求 404
检查三点:SerializeMaps 键是 snake 字段名(status)、字典编码正确(example_status)、DTO 中声明了 `StatusText` 字段。字典本身需在字典管理中有对应项。
A: 检查:
1. `route/app.php` 中是否注册了对应路由
2. 路由路径是否与前端 API 模块中的 `url` 一致
3. HTTP 方法是否正确(GET/POST/PUT/DELETE)
**Q:文件上传后访问 404?**
### Q: 表格数据不显示
上传先进 temp,**保存记录时**才经 saveTransform 转正到 `FileSaveDir`。只调上传接口不保存记录,文件不会出现在正式目录。域名问题见 [7.2 上传配置详解](/zh/tools/upload-config.html)。
A: 检查:
1. API 返回的数据结构是否正确(应包含 `records` 和 `total`)
2. 前端是否正确绑定了 `tableData`
3. 浏览器控制台是否有报错
## 钩子与事务
### Q: 导入失败
**Q:钩子里能做 HTTP 请求 / append 吗?**
A: 检查:
1. 文件格式是否为 `.xls` 或 `.xlsx`
2. 表头是否与 `headerMap` 中定义的中文名一致
3. 枚举字段的值是否在字典中存在
不能。钩子禁 HTTP/文件 IO、禁 append(可就地改元素),错误原样上抛。
**Q:事务里查到的还是旧数据?**
事务内必须用回调入参的 ctx:`db.WithTrans(ctx, func(ctx context.Context) error { ... })`——内层 ctx 才携带事务连接。
## 效率建议
- 建表后先跑 `make gen TABLES="gin_xxx|说明"` 让生成器出四件套与前端,再手工改——比从零手写快且不违例
- 新配置键记得同步 `.env.example`,否则 `make check` 卡 check-config
- 不确定写法时,对照本章 example 模块逐字核对——它是全项目规约的参考实现
@@ -1,275 +1,109 @@
# 3.9 前端联调指引
# 3.10 前端联调指引
:::tip 概述
后端接口开发完成后,需要在前端创建对应的 API 模块和页面组件。本节以 example 模块为例,提供简要指引,详细内容参考[第 9 章](/zh/frontend/overview.html)。
后端四件套就绪后,前端补两块:API 模块(`ui/src/api`)与页面(`ui/src/views`)。本页以 example 模块的真实前端文件为准,深入开发规范见 [第 9 章 前端开发](/zh/frontend/overview.html)。
:::
## 联调流程
## 涉及文件(example 模块实际结构)
```text
1. 创建 API 模块 → ui/src/api/tool/example.ts
2. 创建页面组件 → ui/src/views/tool/example/index.vue
3. 配置菜单组件路径 → 后端菜单管理中设置 component
4. 测试联调
ui/src/api/tool/example.ts # API 模块(九个接口函数)
ui/src/views/tool/example/
├── index.vue # 列表页(BasicTable + 查询表单 + 工具栏按钮)
├── edit.vue # 新增/编辑弹窗(主键为 0 时表示新增)
├── detail.vue # 详情弹窗
├── columns.ts # 表格列配置(render 函数自定义图片/标签渲染)
└── querySchemas.ts # 查询表单配置(FormSchema 声明式)
```
## 步骤一:创建 API 模块
在 `ui/src/api/tool/` 下创建 `example.ts`(来自项目实际代码):
## API 模块
```typescript
// ui/src/api/tool/example.ts
// ui/src/api/tool/example.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,
});
return http.request({ url: '/example/page', method: 'GET', params });
}
/**
* 获取全部案例演示列表
*
* @param params 查询参数
* @returns 案例演示列表
*/
export function getExampleList(params?: Record<string, any>) {
return http.request({
url: '/example/list',
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,
});
return http.request({ url: '/example/add', method: 'POST', data });
}
/**
* 更新案例演示
*
* @param data 案例演示数据,需包含主键 id
* @returns 操作结果
*/
export function exampleUpdate(data: any) {
return http.request({
url: '/example/update',
method: 'PUT',
data,
});
}
/**
* 删除案例演示
*
* @param id 案例演示ID
* @returns 操作结果
*/
export function exampleDelete(id) {
return http.request({
url: '/example/delete/' + id,
method: 'DELETE',
});
}
/**
* 批量删除案例演示
*
* @param data 含 ids 数组的对象,形如 { ids: [1, 2, 3] }
* @returns 操作结果
*/
export function exampleBatchDelete(data: any) {
return http.request({
url: '/example/batchDelete',
method: 'DELETE',
data,
});
}
/**
* 导入案例演示
*
* @param data 文件数据(FormData)
* @returns 导入结果,含成功数量与失败明细
*/
export function exampleImport(data) {
return http.request({
url: '/example/import',
method: 'POST',
data,
});
}
/**
* 导出案例演示
*
* @param params 查询参数
* @returns 导出文件的 URL
*/
export function exampleExport(params) {
return http.request({
url: '/example/export',
method: 'GET',
params,
});
return http.request({ url: '/example/delete/' + id, method: 'DELETE' });
}
```
## 步骤二:创建页面组件
约定:URL 不带 `/api` 前缀(`VITE_GLOB_API_URL_PREFIX` 统一拼接);Token 由请求拦截器自动携带;响应信封由拦截器解包,业务层直接拿 data。
在 `ui/src/views/tool/example/` 下创建以下 5 个文件:
## 页面结构
| 文件 | 说明 |
|------|------|
| `index.vue` | 列表页(表格 + 搜索 + 分页) |
| `edit.vue` | 新增/编辑弹窗表单 |
| `detail.vue` | 详情查看页 |
| `columns.ts` | 表格列定义 |
| `querySchemas.ts` | 搜索表单字段定义 |
列表页(index.vue)骨架摘自 `ui/src/views/tool/example/index.vue`:
:::tip 关于以下示例
以下代码使用原生 `el-table` / `el-form` 简化展示核心逻辑。实际项目中应使用封装好的 `BasicTable` + `useForm` 等组合式组件,详见[第 9 章组件文档](/zh/frontend/overview.html)。
:::
```html
<PageWrapper>
<!-- 查询表单卡片(querySchemas.ts 声明驱动) -->
<el-card :bordered="false" class="pt-3 mb-3 proCard">
<BasicForm @register="register" @submit="handleSubmit" @reset="handleReset" />
</el-card>
`index.vue`(列表页)示例:
```vue
<!-- ui/src/views/tool/example/index.vue -->
<template>
<div>
<!-- 搜索表单 -->
<el-form :model="searchForm" inline>
<el-form-item label="名称">
<el-input v-model="searchForm.name" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择" clearable>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 操作按钮 -->
<el-button type="primary" @click="handleAdd">新增</el-button>
<el-button type="danger" :disabled="!selectedIds.length" @click="handleBatchDelete">
批量删除
</el-button>
<!-- 数据表格 -->
<el-table :data="tableData" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="typeText" label="类型" width="100" />
<el-table-column prop="statusText" label="状态" width="100" />
<el-table-column prop="sort" label="排序" width="80" />
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="pageNo"
v-model:page-size="pageSize"
:total="total"
@current-change="fetchData"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { getExamplePage, exampleDelete, exampleBatchDelete } from '@/api/tool/example';
const searchForm = ref({ name: '', status: '' });
const tableData = ref([]);
const pageNo = ref(1);
const pageSize = ref(20);
const total = ref(0);
const selectedIds = ref([]);
const fetchData = async () => {
const res = await getExamplePage({
pageNo: pageNo.value,
pageSize: pageSize.value,
...searchForm.value,
});
tableData.value = res.records;
total.value = res.total;
};
const handleSearch = () => { pageNo.value = 1; fetchData(); };
const handleReset = () => { searchForm.value = { name: '', status: '' }; handleSearch(); };
const handleDelete = async (id: number) => { await exampleDelete(id); fetchData(); };
const handleBatchDelete = async () => { await exampleBatchDelete({ ids: selectedIds.value }); fetchData(); };
const handleSelectionChange = (rows: any[]) => { selectedIds.value = rows.map((r) => r.id); };
onMounted(() => fetchData());
</script>
<!-- 数据表格卡片(columns.ts 列配置 + 分页请求) -->
<el-card :bordered="false" class="proCard">
<BasicTable
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.id"
:actionColumn="actionColumn"
@selection-change="onSelectionChange"
>
<template #tableTitle>
<el-button type="primary" @click="handleAdd" v-perm="['sys:example:add']">添加案例演示</el-button>
</template>
</BasicTable>
</el-card>
</PageWrapper>
```
## 步骤三:配置菜单
模式要点:`BasicForm`/`BasicTable` 经 `@register` 注册使用;表格数据由 `request` 回调返回(页面里即 `getExamplePage`);工具栏按钮挂 `v-perm` 数组入参。
在后端菜单管理中,将组件路径设置为:
## 列配置与查询配置
```text
tool/example/index
```typescript
// ui/src/views/tool/example/columns.ts(节选)
export const columns = [
{ type: 'selection' },
{ label: '主键ID', prop: 'id', minWidth: 100 },
{ label: '案例名称', prop: 'name', minWidth: 120 },
{ label: '案例图片', prop: 'avatar', minWidth: 120,
render(record) { return h(ElAvatar, { size: 48 /* ... */ }); } },
// ...
];
// ui/src/views/tool/example/querySchemas.ts(节选)
import { FormSchema } from '@/components/Form/index';
export const schemas: FormSchema[] = [
{ field: 'name', component: 'Input', label: '案例名称',
componentProps: { placeholder: '请输入案例名称', clearable: true } },
// 类型/状态用 Select(后端 EqFields 过滤)
];
```
前端路由系统会自动将此路径映射到 `ui/src/views/tool/example/index.vue`。
字典列(statusText)直接配置为普通列显示——显示名已由后端 SerializeMaps 回填。
## 步骤四:测试联调
## 联调检查清单
1. 确保后端服务运行:`php think run`
2. 确保前端服务运行:`pnpm dev`
3. 刷新前端页面,侧边栏应出现"案例演示"菜单
4. 点击菜单,测试增删改查功能
- [ ] 菜单已配置且 `component` 路径指向新页面(否则路由 404)
- [ ] 角色已授权页面与按钮权限节点
- [ ] API 模块函数与 RouteSpec 动作一一对应
- [ ] 表单字段与 dto json 名一致(全量提交语义)
- [ ] 导入走 FormData(`UnmarshalBody` 契约,页面经 importFile 组件),导出拿 URL 下载
- [ ] 字典下拉走字典接口取值,不硬编码枚举
详细前端开发内容请参考[第 9 章](/zh/frontend/overview.html)。
## 用生成器起步
`make gen` 会同时产出 API 模块与上述五个页面文件(树形表走 ui2 模板),生成后按业务微调即可。生成细节见 [第 10 章 代码生成器](/zh/generator/overview.html)。
## 下一步
→ [3.11 完整代码清单](/zh/guide/code-list.html)
@@ -0,0 +1,81 @@
# 3.6 接口层开发(handler)
:::tip 概述
handler 是极薄委托层:`router.Bind` 反射已完成参数解析与 binding 校验,本层只做一次调用转换。**业务规则一律在 service,handler 不写逻辑。**
:::
## 标准结构
```go
// internal/handler/example.go
package handler
import (
"context"
"easygoadmin/internal/model/dto"
"easygoadmin/internal/service"
)
// CExample 案例演示控制器
type CExample struct{}
// Page 案例演示分页(GET /api/example/page),委托 service.Example.Page
func (c *CExample) Page(ctx context.Context, req *dto.ExamplePageReq) (*dto.PageData[dto.ExampleItem], error) {
return service.Example.Page(ctx, req)
}
// Add 新增案例演示(POST /api/example/add),委托 service.Example.Add
func (c *CExample) Add(ctx context.Context, req *dto.ExampleAddReq) (*dto.ExampleIdRes, error) {
return service.Example.Add(ctx, req)
}
```
## 方法签名铁律
每个对外方法签名**必须**是:
```go
func (c *CXxx) Method(ctx context.Context, req *dto.XxxReq) (*dto.XxxRes, error)
```
路由反射扫描靠这个签名识别方法并绑定请求——签名不匹配的方法不会被注册(也不会报错,表现为 404)。返回值可以是结构体指针、类型别名(如 `dto.ExampleListRes`)或 `*dto.EmptyRes`。
## 手写逻辑方法的注释规约
非标准方法(导入/导出/重置密码/分配菜单等)保留为 handler 方法,按"入参/返回/副作用"三段注释:
```go
// internal/handler/example.go
// Import 导入案例演示(POST /api/example/import),委托 service.Example.Import
// 入参:multipart 表单 xls/xlsx 文件(dto 实现 UnmarshalBody 解析文件字段)。
// 返回:导入总数、成功数、失败数及失败原因列表;错误:文件缺失或格式非法时上抛。
// 副作用:解析表格并批量写入案例演示表。
func (c *CExample) Import(ctx context.Context, req *dto.ExampleImportReq) (*dto.ExampleImportRes, error) {
return service.Example.Import(ctx, req)
}
```
## handler 不做的事
| 禁则 | 去处 |
|---|---|
| 参数解析与校验 | router.Bind(binding 标签驱动)+ dto |
| 权限判断 | authz 中间件(RouteSpec.Permission 声明) |
| 操作日志 | operation_log 中间件(RouteSpec.Log 声明) |
| 业务规则与事务 | service |
| 数据访问 | repository(经 service 调用) |
## 新控制器接入
在 `internal/router/router.go` 的 `registerControllers` 中加一条 `Bind` 声明(含 RouteSpec 表)——见下一页 [3.7 路由与元数据注册](/zh/guide/route.html)。代码生成器会自动完成这一步。
## 编写检查清单
- [ ] 控制器名 `CXxx`,空结构体
- [ ] 方法与 dto 请求结构一一对应,签名完全匹配
- [ ] 方法体只有一行委托(或极少量调用转换)
- [ ] 注释给出路由方法与路径
## 下一步
→ [3.7 路由与元数据注册(RouteSpec)](/zh/guide/route.html)
-319
View File
@@ -1,319 +0,0 @@
# 3.5 逻辑层开发
:::tip 概述
逻辑层是业务核心,通过"属性配置 + 生命周期钩子"实现声明式 CRUD。子类只需配置属性和按需重写钩子,即可获得完整的增删改查能力。
:::
## 逻辑层文件位置
```text
app/logic/ExampleLogic.php
```
## 完整代码(来自项目实际代码)
```php
<?php
declare(strict_types=1);
namespace app\logic;
use app\BaseLogic; // 系统共享模块基类
use app\model\Example; // 案例演示模型
/**
* 案例演示业务逻辑类
*
* 继承 BaseLogic,通过属性配置声明式地定义案例演示模块的通用行为,
* 并按需生成导入、导出方法。
*
* 配置覆盖:文件字段、查询条件、排序、唯一性校验、枚举显示名映射。
*/
class ExampleLogic extends BaseLogic
{
/**
* 关联的模型类
*
* 指定该逻辑层操作的数据模型为 Example。
* BaseLogic 的所有 CRUD 方法都基于此模型。
*
* @var string
*/
protected string $modelClass = Example::class;
/**
* 验证器类名
*
* 设置后,add/update 时自动调用对应验证器的 add/update 场景进行参数校验。
* 校验失败抛出 ValidateException,由 ExceptionHandle 统一返回。
*
* @var string
*/
protected string $validateClass = \app\validate\ExampleValidate::class;
/**
* 文件字段
*
* 新增/修改时迁移临时文件并去掉域名,
* 查询时自动补全完整访问 URL。
*
* @var array
*/
protected array $fileFields = ['avatar'];
/**
* 多文件字段(逗号分隔的多个URL)
*
* 与 fileFields 不同,这些字段的值是逗号分隔的多个文件URL,
* 新增/修改时逐个迁移临时文件并去掉域名,
* 查询时逐个补全完整访问 URL。
*
* @var array
*/
protected array $multiFileFields = ['images'];
/**
* 文件保存子目录
*
* 文件字段上传后保存到该子目录下。
*
* @var string
*/
protected string $fileSaveDir = 'example';
/**
* LIKE 模糊匹配字段
*
* 分页查询时,前端传入的 name 参数会自动使用 LIKE 查询。
* 例如:GET /api/example/page?name=测试
* 生成 SQL:WHERE name LIKE '%测试%'
*
* @var array
*/
protected array $pageLikeFields = ['name'];
/**
* 精确匹配字段
*
* 分页查询时,前端传入的 type 和 status 参数
* 会自动使用 = 查询。
* 例如:GET /api/example/page?type=1&status=1
* 生成 SQL:WHERE type = 1 AND status = 1
*
* @var array
*/
protected array $pageEqFields = ['type', 'status'];
/**
* 默认排序规则
*
* 支持两种格式:
* - 单字段:['field' => 'sort', 'type' => 'asc']
* - 多字段:[['field' => 'sort', 'type' => 'asc'], ['field' => 'id', 'type' => 'desc']]
*
* 前端可通过 orderField/orderType 参数覆盖。
*
* @var array
*/
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
/**
* 枚举显示名映射
*
* 查询数据时自动根据字典编码翻译值为描述文本,补 {字段名}Text 字段。
* 例如:type=1 时,typeText='类型一'(从字典 example_type 读取)。
* status=1 时,statusText='启用'(从字典 example_status 读取)。
*
* @var array
*/
protected array $serializeMaps = [
'type' => 'example_type',
'status' => 'example_status',
];
// ... 其他属性配置
}
```
:::tip 属性配置即全部
子类只需声明以上属性,无需编写任何 CRUD 代码,即可获得完整的分页查询、详情、新增、修改、删除能力。这是 BaseLogic 模板方法模式的核心优势。
:::
## 扩展方法(导入导出示例)
```php
/**
* 导入案例演示数据
*
* 处理流程:
* 1. 按表单字段生成"中文表头 => 字段名"的映射,跳过图片与富文本字段;
* 2. 调用 ExcelService::importWithMap 解析文件为关联数组;
* 3. 枚举字段反向映射:把文字通过 DictService 还原为数值;
* 4. 逐行调用 add 方法写入,成功计数,失败收集行号与原因;
* 5. 返回成功数与错误列表。
*
* @param string $filePath 文件路径
* @return array ['count' => 成功数, 'errors' => [失败行信息]]
* @throws \Exception 文件解析失败时抛出
*/
public function import(string $filePath): array
{
// 定义表头映射:Excel 表头名 → 数据库字段名
$headerMap = [
'案例名称' => 'name',
'案例类型' => 'type',
'案例状态' => 'status',
'案例排序' => 'sort',
];
// 解析 Excel 文件为关联数组
$data = \app\service\ExcelService::importWithMap($filePath, $headerMap);
// 枚举字段反向映射:文字转数字
// 例如 Excel 中"启用" → 数据库中 1
foreach ($data as &$row) {
if (!empty($row['type']) && !is_numeric($row['type'])) {
$row['type'] = \app\service\DictService::getValue('example_type', $row['type']);
}
if (!empty($row['status']) && !is_numeric($row['status'])) {
$row['status'] = \app\service\DictService::getValue('example_status', $row['status']);
}
}
unset($row);
// 逐行导入
$count = 0;
$errors = [];
foreach ($data as $index => $row) {
try {
$this->add($row); // 调用 BaseLogic::add,触发 beforeAdd/afterAdd 钩子
$count++;
} catch (\Exception $e) {
// 单行失败不影响其它行,收集错误信息
$errors[] = [
'row' => $index + 2, // +2 因为第1行是表头,索引从0开始
'reason' => $e->getMessage(),
];
}
}
return ['count' => $count, 'errors' => $errors];
}
/**
* 导出案例演示数据
*
* 处理流程:
* 1. 按列表字段生成导出表头,跳过图片与富文本字段;
* 带选项的字段使用 {字段名}Text,以导出显示名而非原始值;
* 2. 追加创建时间列;
* 3. 调用 ExcelService::chunkExport 分批导出,每批 500 条,
* 避免一次性加载全部数据导致内存溢出。
*
* @param array $params 查询参数
* @return string 导出文件的 URL
* @throws \Exception 导出失败时抛出
*/
public function export(array $params = []): string
{
// 定义导出表头:字段名 → Excel 表头名
// 带 Text 后缀的字段取枚举显示名(如 typeText='类型一')
$headers = [
'name' => '案例名称',
'typeText' => '案例类型', // 枚举显示名
'statusText' => '案例状态', // 枚举显示名
'sort' => '案例排序',
'create_time' => '创建时间',
];
// 分批导出,每批 500 条
return \app\service\ExcelService::chunkExport($headers, function (int $page) use ($params) {
$chunk = [];
// chunkList 是 BaseLogic 提供的分批查询方法
$this->chunkList($params, function (array $data) use (&$chunk) {
$chunk = $data;
}, 500);
return $chunk;
}, '案例演示数据.xlsx', 500);
}
}
```
## 属性配置速查
| 属性 | 类型 | 说明 | 本例配置 |
|------|------|------|----------|
| `modelClass` | string | 关联模型类名(必须) | `Example::class` |
| `validateClass` | string | 验证器类名(自动参数校验) | `ExampleValidate::class` |
| `fileFields` | array | 单文件字段列表 | `['avatar']` |
| `multiFileFields` | array | 多文件字段列表(逗号分隔的多个URL) | `['images']` |
| `fileSaveDir` | string | 文件保存子目录 | `'example'` |
| `pageLikeFields` | array | LIKE 模糊匹配字段 | `['name']` |
| `pageEqFields` | array | 精确匹配字段 | `['type', 'status']` |
| `pageOrderBy` | array | 默认排序规则 | `['field'=>'sort','type'=>'asc']` |
| `uniqueFields` | array | 唯一性校验字段 | `[]`(本例未配置) |
| `serializeMaps` | array | 枚举显示名映射 | `['type'=>'example_type', 'status'=>'example_status']` |
| `contentFields` | array | 富文本字段 | `[]`(本例未配置) |
## 生命周期钩子
| 钩子 | 时机 | 用途 |
|------|------|------|
| `beforeAdd($data)` | 新增前 | 修改数据、校验、抛异常拦截 |
| `afterAdd($id, $data)` | 新增后 | 保存关联表、发送通知 |
| `beforeUpdate($id, $data)` | 修改前 | 修改数据、校验 |
| `afterUpdate($id, $data)` | 修改后 | 更新关联表 |
| `beforeDelete($id)` | 删除前 | 检查是否有子级、抛异常拦截 |
| `afterDelete($id)` | 删除后 | 清理关联数据 |
| `afterDetail($id, &$data)` | 详情查询后 | 补充关联数据、格式化输出 |
| `afterPageList(&$records, $params)` | 列表查询后 | 批量补充关联数据(分页和全量列表均触发) |
### afterPageList 示例
当列表需要补充关联统计数据时,`afterPageList` 比逐条 `afterDetail` 更高效,只需一次 SQL 查询:
```php
/**
* 列表查询后处理:批量补充评论数
*
* @param array &$records 记录列表(引用传递)
* @param array $params 查询参数
* @return void
*/
protected function afterPageList(array &$records, array $params): void
{
if (empty($records)) return;
// 一次查询获取所有记录的评论数,避免 N+1
$ids = array_column($records, 'id');
$counts = Comment::whereIn('article_id', $ids)
->group('article_id')
->column('count(*)', 'article_id');
foreach ($records as &$record) {
$record['commentCount'] = $counts[$record['id']] ?? 0;
}
}
```
:::tip 与 afterDetail 的区别
- `afterDetail`:单条详情查询后触发,适合补充单条记录的关联数据
- `afterPageList`:列表查询后触发(分页和全量均适用),适合批量补充关联数据,避免 N+1 查询
:::
## 事务处理
```php
public function addWithRelated(array $data): int
{
$this->startTrans(); // 开启事务
try {
$id = $this->add($data);
// 保存关联数据...
$this->commit(); // 提交事务
return $id;
} catch (\Exception $e) {
$this->rollback(); // 回滚事务
throw $e;
}
}
```
-103
View File
@@ -1,103 +0,0 @@
# 3.3 模型层开发
:::tip 概述
模型层负责定义数据库表的映射关系,继承 BaseModel 后自动获得软删除和审计字段能力。
:::
## 模型文件位置
```text
app/model/Example.php
```
## 完整代码(来自项目实际代码)
```php
<?php
declare(strict_types=1);
namespace app\model;
use app\BaseModel;
/**
* 案例演示模型
*
* 对应数据表 example,继承 BaseModel 获得通用模型能力
* (软删除与审计字段自动写入)。
*
* 说明:本模型只有表映射,未定义关联或辅助方法;
* 案例演示的查询过滤、排序、唯一校验等均由 ExampleLogic 配置。
*/
class Example extends BaseModel
{
/**
* 数据表名
*
* 显式指定表名,覆盖默认的类名推导规则。
* ThinkPHP 默认使用类名的小写下划线形式作为表名,
* Example → think_example。如果表名与类名不对应,
* 可通过 $name 属性显式指定。
*
* @var string
*/
protected $name = 'example';
}
```
## 继承 BaseModel 后获得的能力
| 能力 | 说明 | 代码示例 |
|------|------|----------|
| 软删除 | 全局查询自动过滤 `is_delete=1` | `$list = Example::select();` 自动排除已删除 |
| 软删除操作 | 设置 `is_delete=1` | `$model->softDelete();` |
| 批量软删除 | 批量设置 `is_delete=1` | `Example::batchSoftDelete([1,2,3]);` |
| 审计字段-创建 | 自动填充 `create_user` 和 `create_time` | `Example::create([...])` 时自动填充 |
| 审计字段-更新 | 自动填充 `update_user` 和 `update_time` | `$model->save([...])` 时自动填充 |
## 查询已删除数据
```php
// 查询单条(包括已删除)
$example = Example::withoutGlobalScope(['soft_delete'])->find($id);
// 查询所有(包括已删除)
$all = Example::withoutGlobalScope(['soft_delete'])->select();
```
## 简单模型 vs 复杂模型
:::tip 模型层职责
模型层只负责表映射和关联关系,不包含业务逻辑。所有查询条件、排序、唯一性校验等都由 Logic 层配置。
:::
### 简单模型(如 Example)
只需声明表名,所有逻辑由 Logic 层配置:
```php
class Example extends BaseModel
{
protected $name = 'example';
}
```
### 复杂模型(如 Role)
包含关联关系:
```php
class Role extends BaseModel
{
protected $name = 'role';
/**
* 关联菜单(多对多)
* 通过 role_menu 中间表关联
*/
public function menus()
{
return $this->belongsToMany(Menu::class, 'role_menu', 'menu_id', 'role_id');
}
}
```
+37 -36
View File
@@ -1,88 +1,89 @@
# 3.1 概述与目标
:::tip 概述
本章以项目内置的 **example(案例演示)** 模块为例,从数据库建表到前端页面联调,手把手演示开发全流程。学完本章后,您将掌握新增业务模块的标准步骤。
本章以项目内置的 **example(案例演示,`gin_example`)** 模块为例,从数据库建表到前端页面联调,完整演示开发全流程。学完本章后,你将掌握新增业务模块的标准步骤。
:::
## 开发流程总览
```text
┌──────────┐
│ 1. 建表 │ 创建数据库表(含公共字段)
│ 1. 建表 │ 创建 gin_ 前缀表(含公共审计字段)
└────┬─────┘
│
▼
┌──────────┐
│ 2. Model │ 创建数据模型(继承 BaseModel)
│ 2. dto │ internal/model/dto/{module}.go —— 请求/响应契约 + 校验标签
└────┬─────┘
│
▼
┌──────────┐
│ 3. Validate│ 创建验证器 + Logic 配置 validateClass
│ 3. repo │ internal/repository/{module}.go —— XxxRepo + Base,统一 Ctx(ctx)
└────┬─────┘
│
▼
┌──────────┐
│ 4. Logic │ 创建逻辑层(配置属性 + 钩子)
│ 4. service│ internal/service/{module}.go —— sXxx + ModuleSpec 声明 + 手写逻辑
└────┬─────┘
│
▼
┌──────────┐
│ 5. Controller│ 创建控制器(注解 + 异常处理)
│ 5. handler│ internal/handler/{module}.go —— 极薄委托,签名固定
└────┬─────┘
│
▼
┌──────────┐
│ 6. Route │ 注册路由
│ 6. 路由 │ router.go 注册表加一条 Bind 声明(RouteSpec 表)
└────┬─────┘
│
▼
┌──────────┐
│ 7. Menu │ 配置菜单权限(可选)
│ 7. 菜单 │ 配置菜单与权限节点(或直接用代码生成器代劳)
└────┬─────┘
│
▼
┌──────────┐
│ 8. Frontend│ 前端 API + 页面([详见第 9 章](/zh/frontend/overview.html))
│ 8. 前端 │ ui/src/api + ui/src/views(详见第 9 章)
└──────────┘
```
第 2~6 步也可以交给 [第 10 章 代码生成器](/zh/generator/overview.html) 自动完成——生成器产物与手写规约完全一致,本章教你读懂并手工掌握每一步。
## 开发约定
| 约定 | 说明 | 示例 |
|------|------|------|
| 表名 | `think_` 前缀 + 小写下划线 | `think_example` |
| 字段名 | 小写下划线 | `create_time`、`user_id` |
| 主键 | 统一使用 `id`,INT 自增 | `id INT PRIMARY KEY AUTO_INCREMENT` |
| Model | 继承 `BaseModel`,类名与表名对应 | `class Example extends BaseModel` |
| Logic | 继承 `BaseLogic` 或 `BaseTenantLogic` | `class ExampleLogic extends BaseLogic` |
| Controller | 继承 `BaseController` | `class ExampleController extends BaseController` |
| 权限码 | `sys:模块名:操作` | `sys:example:add` |
| 路由 | RESTful 风格 | `GET /api/example/page` |
| 表名 | `gin_` 前缀 + 小写下划线 | `gin_example` |
| 字段名 | 小写下划线 | `create_user`、`sort` |
| 主键 | 统一 `id`,自增 | `id INT PRIMARY KEY AUTO_INCREMENT` |
| 四件套 | dto / repository / service / handler 同名成套 | `example.go` × 4 |
| 服务实例 | `type sXxx struct{}` + init 注册 | `var Example *sExample` |
| 权限码 | `sys:模块名:操作` | `sys:example:page` |
| 路由风格 | 动词命名、全小驼峰方法 | `GET /api/example/page` |
| 日志文案 | `模块名-动作描述` | `案例演示-查询分页记录` |
## 本章示例
## 本章示例覆盖场景
本章以项目内置的 **example(案例演示)** 模块为例,完整演示从建表到联调的全过程。该模块是代码生成器的参考模板,涵盖了标准 CRUD 的所有典型场景:
`gin_example` 是代码生成器的参考模板,涵盖标准 CRUD 的全部典型场景:
- 分页列表(支持名称模糊搜索、类型和状态精确过滤)
- 全量列表
- 分页列表(名称模糊、类型/状态等值过滤、字典显示名回填)
- 全量列表(limit 上限)
- 详情查询
- 新增(含头像上传)
- 修改
- 删除 / 批量删除
- Excel 导入导出
- 权限控制(#[Permission] 注解)
- 操作日志(#[Log] 注解)
- 枚举显示名翻译(type / status 字典映射)
- 新增 / 修改(含单图字段 temp 转正)
- 单列状态更新(手写逻辑)
- 删除 / 批量删除(软删除)
- Excel 导入(multipart 文件 + 逐行校验)
- Excel 导出(条件过滤 + 字典翻译 + 文件 URL 返回)
- 权限控制与操作日志(RouteSpec 声明驱动)
## 涉及文件
```text
app/model/Example.php # 数据模型
app/validate/ExampleValidate.php # 验证器
app/logic/ExampleLogic.php # 业务逻辑
app/controller/ExampleController.php # 控制器
route/app.php # 路由(第371行)
ui/src/api/tool/example.ts # 前端 API 模块
ui/src/views/tool/example/ # 前端页面
```
internal/model/dto/example.go # 契约层
internal/repository/example.go # 数据访问层
internal/service/example.go # 业务层(ModuleSpec 声明)
internal/handler/example.go # 接口层
internal/router/router.go # RouteSpec 注册表
ui/src/api/tool/example.ts # 前端 API 模块
ui/src/views/tool/example/ # 前端页面
```
+66 -70
View File
@@ -1,76 +1,72 @@
# 3.8 权限节点配置
# 3.9 权限节点配置
:::tip 概述
权限节点用于控制用户能访问哪些接口。通过菜单管理界面配置,格式为 `sys:{模块名}:{操作}`。
接口侧的权限码在 RouteSpec 声明,但"谁能访问"由菜单-角色链路决定:菜单表登记权限节点,角色勾选授权,authz 中间件按元数据校验。本页说明新模块的权限接入步骤。
:::
## Example 模块权限码
| 权限码 | 说明 | 对应接口 |
|--------|------|----------|
| `sys:example:page` | 案例演示分页列表 | `GET /api/example/page` |
| `sys:example:add` | 添加案例演示 | `POST /api/example/add` |
| `sys:example:update` | 修改案例演示 | `PUT /api/example/update` |
| `sys:example:delete` | 删除案例演示 | `DELETE /api/example/delete/:id` |
| `sys:example:import` | 导入案例演示 | `POST /api/example/import` |
| `sys:example:export` | 导出案例演示 | `GET /api/example/export` |
## 配置步骤
### 1. 添加目录节点
在系统菜单管理中添加一级目录:
| 字段 | 值 |
|------|-----|
| 菜单名称 | 工具管理 |
| 菜单类型 | 目录(type=0) |
| 路由地址 | /tool |
| 排序 | 60 |
### 2. 添加菜单节点
在目录下添加二级菜单:
| 字段 | 值 |
|------|-----|
| 菜单名称 | 案例演示 |
| 菜单类型 | 菜单(type=1) |
| 路由地址 | /example |
| 组件路径 | tool/example/index |
| 权限标识 | sys:example:page |
| 排序 | 1 |
### 3. 添加按钮权限
在菜单下添加三级按钮权限:
| 字段 | 值 | 权限标识 |
|------|-----|----------|
| 添加案例演示 | type=2 | sys:example:add |
| 修改案例演示 | type=2 | sys:example:update |
| 删除案例演示 | type=2 | sys:example:delete |
| 导入案例演示 | type=2 | sys:example:import |
| 导出案例演示 | type=2 | sys:example:export |
### 4. 分配角色权限
在角色管理中,为角色勾选新增的权限节点。
## 菜单类型说明
| type | 说明 | 前端行为 |
|------|------|----------|
| 0 | 目录 | 渲染为侧边栏分组 |
| 1 | 菜单 | 渲染为可点击的菜单项,对应一个页面 |
| 2 | 按钮 | 不渲染,仅用于权限校验 |
## 权限校验流程
## 权限判定链路
```text
1. 用户登录 → 获取用户角色
2. 角色关联菜单 → 获取权限节点列表
3. 请求接口 → AuthMiddleware 读取 #[Permission] 注解
4. 检查权限码是否在用户权限列表中
5. 超级管理员(uid=1)跳过校验,直接放行
```
RouteSpec.Permission(如 sys:example:page)
│ Bind 注册时落路由元数据表
▼
authz 中间件:解析当前用户 → 查角色授权的权限集合 → 命中即放行
│
▼
菜单表 gin_menu.permission 登记节点 → 角色管理勾选 → gin_role_menu 关联
```
注意:**RouteSpec 声明是校验依据,菜单登记是授权入口**——两处的权限码字符串必须一致。只配 RouteSpec 不配菜单,任何人都无法获得该接口权限;只配菜单不配 RouteSpec,接口实际处于"登录即可"状态。
## 菜单表关键字段
```text
gin_menu
├── name 菜单名称
├── parent_id 上级ID(目录/菜单树)
├── path 前端路由路径
├── component 前端组件路径(views 下的相对路径)
├── permission 权限节点(sys:example:page)
├── type 菜单类型:0-菜单 1-节点
├── target 打开方式:0-组件 1-内链 2-外链
├── status 状态:0-正常 1-停用
└── sort 排序
```
`type=1` 的"节点"类型专门承载纯权限点(不显示为菜单项),页面按钮级权限通常配成节点挂在页面菜单下。
## 新模块权限配置步骤
1. **目录**:菜单管理下为新模块建一个目录(或复用现有目录,生成器默认挂 `generator.parentMenuId` 指定的父级)
2. **菜单**:建页面菜单,`path` 与前端路由一致,`component` 指向 `ui/src/views` 下页面
3. **节点**:按 RouteSpec 中每个权限码建 `type=1` 节点,`permission` 逐字一致
4. **授权**:角色管理勾选新节点,保存(写入 gin_role_menu)
5. **验证**:用受限角色登录,页面可见、按钮按 `v-permission` 显隐、直连接口 403/放行符合预期
## 按钮级权限(前端)
前端用 `v-perm`(包含其一)/ `v-perms`(包含全部)指令按权限码控制按钮:
```html
<!-- ui/src/views/tool/example/index.vue 实际用法(注意:入参必须是数组) -->
<el-button type="primary" @click="handleAdd" v-perm="['sys:example:add']">添加案例演示</el-button>
<el-button type="danger" @click="handleDelete()" v-perm="['sys:example:batchDelete']">批量删除</el-button>
```
超管权限集合含通配码 `*:*:*`,指令判定直通。用户菜单接口(`/api/index/getMenus`)返回的菜单树已按角色过滤,节点权限码一并下发供前端比对。详见 [9.7 权限控制](/zh/frontend/permission.html)。
## 代码生成器代劳
生成器会按 RouteSpec 幂等创建菜单行(含页面菜单与权限节点),配置 `generator.parentMenuId` 指定挂载位置——手工开发时可以参照生成器的菜单行结构照抄。
## 常见问题
| 现象 | 原因 |
|---|---|
| 接口 403 | 角色未授权该权限码,或菜单 permission 与 RouteSpec 拼写不一致 |
| 接口通但页面按钮不显示 | 前端 v-perm 的码与菜单节点不一致 |
| 不配权限码接口却能访问 | RouteSpec.Permission 留空 = 登录即可,按需补声明 |
## 下一步
→ [3.10 前端联调指引](/zh/guide/frontend-integration.html)
@@ -0,0 +1,105 @@
# 3.4 数据访问层(repository)
:::tip 概述
仓储层做一件事:给 service 一个安全的数据库入口。**铁律:禁止裸用 `*gorm.DB`**——统一经 `repository.NewXxxRepo().Ctx(ctx)` 访问,软删过滤与租户隔离自动注入。
:::
## 标准结构
```go
// internal/repository/example.go
package repository
import (
"context"
"gorm.io/gorm"
)
// ExampleRepo 案例演示仓储
type ExampleRepo struct {
Base
}
// NewExampleRepo 构造案例演示仓储(表名 = gin_ + 模块名)
func NewExampleRepo() *ExampleRepo {
return &ExampleRepo{Base: NewBase("gin_example")}
}
// Ctx 带 ctx 的查询入口
func (r *ExampleRepo) Ctx(ctx context.Context) *gorm.DB {
return r.Base.Ctx(ctx)
}
```
就这么短——结构、构造、Ctx 三段是每个仓储的固定模板,自定义查询方法按需追加在同文件内。关联表(user_role / role_menu)同样建仓储。
## Base 提供的能力
`Ctx(ctx)` 返回的 `*gorm.DB` 已自动注入:
| 注入项 | 条件 |
|---|---|
| `del_flag = 0` | 所有表——已软删行对查询不可见 |
| `tenant_id IN (0, 自身租户)` | 仅租户白名单表且当前身份非平台级(超管)时 |
Base 附带的便捷方法:
```go
// 软删除(业务删除统一入口,UPDATE del_flag=1)
repo.SoftDelete(ctx, "id", id)
repo.SoftDeleteByID(ctx, id)
// 存在性判断(软删/租户已过滤)
ok, err := repo.ExistsByCond(ctx, "name", name)
```
## GORM 查询写法要点
| 场景 | 写法 |
|---|---|
| 等值条件 | `.Where("col = ?", v)` |
| 非等值 | `.Where("id <> ?", id)` |
| IN 条件 | `.Where("col IN ?", slice)` |
| LIKE | `.Where(col+" LIKE ?", "%"+pat+"%")` |
| 指定列 | `.Select("a,b")` |
| 单行读 map | `.Take(&one)`(one 为 `map[string]any`,判空用 `gorm.ErrRecordNotFound`) |
| 多行读 | `.Find(&rows)`(`[]map[string]any` 或 `*[]*T`) |
| 计数 | `.Count(&n)`(n 为 int64) |
| map 插入 | `.Create(&d)`(d 为 `map[string]any`) |
| map 更新 | `.Where(..).Updates(d)` |
| 分页 | `.Offset((p-1)*size).Limit(size).Find(&list)` |
| 排序 | `.Order("sort, id")` |
| 结构体扫描 | Find 直接扫结构体(无 gorm 标签时按字段名蛇形映射;非列字段加 `gorm:"-"`) |
## Raw 入口(受限场景)
```go
repo.Raw(ctx) // 不注入软删/租户 Scope
```
`Raw()` 仅限**导入、字典管理、登录校验**等明确场景使用——已软删行会进入结果集、跨租户数据可见,条件完整性由使用者自行保证。常规业务查询一律走 `Ctx`。
## 事务
```go
// db.WithTrans:事务内所有 Ctx 查询自动复用事务连接
err := db.WithTrans(ctx, func(ctx context.Context) error {
if err := repoA.Ctx(ctx).Create(&d1).Error; err != nil {
return err // 返回错误自动回滚
}
return repoB.Ctx(ctx).Create(&d2).Error
})
```
事务内务必使用回调入参的 ctx(而非外层 ctx),否则拿不到事务连接。
## 编写检查清单
- [ ] 表名 `gin_` 前缀 + 模块名
- [ ] 只有 Ctx / Raw 两个查询入口,无裸 `*gorm.DB` 字段外泄
- [ ] 删除走 SoftDelete 系列,不写物理 DELETE
- [ ] Raw 使用处有注释说明场景正当性
## 下一步
→ [3.5 业务层开发(service)](/zh/guide/service.html)
+73 -77
View File
@@ -1,92 +1,88 @@
# 3.7 路由注册
# 3.7 路由与元数据注册(RouteSpec)
:::tip 概述
路由定义在 `route/app.php` 中,按模块分组注册。所有需认证的接口统一绑定四个中间件。
本项目的路由**只有一处声明**:`router.go` 内每个控制器的 RouteSpec 表。注册、权限校验、操作日志、公开白名单、OpenAPI 文档全部从这一份声明派生,天然同步。
:::
## 路由文件位置
## RouteSpec 字段
```go
// internal/router/bind.go
type RouteSpec struct {
Method string // HTTP 方法:GET/POST/PUT/DELETE(必填,否则不注册)
Path string // 路由模板,如 /api/link/detail/:id(必填)
Summary string // 接口摘要(OpenAPI)
Permission string // 权限节点 sys:link:page;空表示登录即可
Log string // 操作日志标题;空表示不记录
LogType string // 日志类型:query/add/update/delete/import/export/reset…
Public bool // 免登录(登录、验证码等)
}
```
## 注册写法
在 `internal/router/router.go` 的 `registerControllers` 内,为控制器集中声明(方法名 → 路由元数据,按字典序排列):
```go
// internal/router/router.go
Bind(rg, new(handler.CExample), map[string]RouteSpec{
"Add": {Method: "POST", Path: "/api/example/add", Summary: "新增案例演示", Permission: "sys:example:add", Log: "案例演示-新增记录", LogType: "add"},
"BatchDelete": {Method: "DELETE", Path: "/api/example/batchDelete", Summary: "批量删除案例演示", Permission: "sys:example:batchDelete", Log: "案例演示-批量删除记录", LogType: "delete"},
"Delete": {Method: "DELETE", Path: "/api/example/delete/:id", Summary: "删除案例演示", Permission: "sys:example:delete", Log: "案例演示-删除记录", LogType: "delete"},
"Detail": {Method: "GET", Path: "/api/example/detail/:id", Summary: "案例演示详情", Permission: "sys:example:detail", Log: "案例演示-查询详情", LogType: "query"},
"Export": {Method: "GET", Path: "/api/example/export", Summary: "导出案例演示", Permission: "sys:example:export", Log: "案例演示-导出数据", LogType: "export"},
"Import": {Method: "POST", Path: "/api/example/import", Summary: "导入案例演示", Permission: "sys:example:import", Log: "案例演示-导入数据", LogType: "import"},
"List": {Method: "GET", Path: "/api/example/list", Summary: "案例演示列表", Permission: "sys:example:list", Log: "案例演示-查询列表", LogType: "query"},
"Page": {Method: "GET", Path: "/api/example/page", Summary: "案例演示分页", Permission: "sys:example:page", Log: "案例演示-查询分页记录", LogType: "query"},
"Status": {Method: "PUT", Path: "/api/example/status", Summary: "案例演示状态", Permission: "sys:example:status", Log: "案例演示-设置状态", LogType: "update"},
"Update": {Method: "PUT", Path: "/api/example/update", Summary: "修改案例演示", Permission: "sys:example:update", Log: "案例演示-修改记录", LogType: "update"},
})
```
## 注册后发生什么
`router.Bind` 反射扫描控制器:
```text
route/app.php
按签名筛选方法(ctx, *dto.Req)→(*dto.Res, error)
│
▼
按方法名查 RouteSpec 表 → rg.Handle(Method, Path, 包装函数)
│
▼
包装函数内:绑定解析(path > body > query)→ binding 校验 → 调 handler → 信封响应
│
▼
写入路由元数据表(方法名、路径、权限码、日志配置、公开标记)
│
▼
authz / operation_log / public / OpenAPI 从元数据表消费同一份声明
```
## Example 模块路由(来自项目实际代码)
## 声明规约
```php
// route/app.php 第 370~383 行
| 项 | 规约 |
|---|---|
| 路径 | `/api/{模块}/{动作}`,路径参数用 `:id` |
| 方法映射 | 查询 GET / 新增 POST / 修改 PUT / 删除 DELETE |
| 权限码 | `sys:{模块}:{动作}`,动作与路径动词一致 |
| Permission 为空 | 登录即可访问(不校验权限点) |
| Log 为空 | 不记录操作日志;查询类是否记录还受 `operationLog.logQuery` 开关控制 |
| Public | 仅登录、验证码、令牌兑换等真正公开接口使用 |
| 顺序 | 表内按方法名字典序排列,与既有条目风格逐字一致 |
// ----------------------------------------------------------
// 自动生成 - example
// ----------------------------------------------------------
Route::group('example', function () {
Route::get('page', 'ExampleController/page'); // 分页列表
Route::get('list', 'ExampleController/list'); // 全量列表
Route::get('detail/:id', 'ExampleController/detail'); // 详情(:id 为路径参数)
Route::post('add', 'ExampleController/add'); // 新增
Route::put('update', 'ExampleController/update'); // 修改
Route::delete('delete/:id', 'ExampleController/delete'); // 删除
Route::delete('batchDelete', 'ExampleController/batchDelete'); // 批量删除
Route::post('import', 'ExampleController/import'); // 导入
Route::get('export', 'ExampleController/export'); // 导出
});
```
## 新增接口的完整动作
## 路由格式说明
1. dto 加请求/响应结构
2. service 加方法
3. handler 加同签名方法
4. RouteSpec 表加一条声明
5. 需要权限点时在菜单管理中配置权限节点(见下一页)
```php
Route::HTTP方法('路径', '控制器/方法');
```
## 底层机制深入
| HTTP 方法 | 用途 | 路径格式 | 示例 |
|-----------|------|----------|------|
| GET | 查询 | `/module/page`、`/module/detail/:id` | `GET /api/example/page` |
| POST | 新增/导入 | `/module/add`、`/module/import` | `POST /api/example/add` |
| PUT | 修改 | `/module/update` | `PUT /api/example/update` |
| DELETE | 删除 | `/module/delete/:id`、`/module/batchDelete` | `DELETE /api/example/delete/1` |
反射细节、元数据表结构、中间件如何消费元数据 → [4.4 反射元数据驱动机制](/zh/architecture/bind-mechanism.html)。
## 标准路由模板
## 下一步
每个业务模块通常包含以下路由:
```php
Route::group('模块名', function () {
Route::get('page', 'XxxController/page'); // 分页列表
Route::get('list', 'XxxController/list'); // 全量列表(可选)
Route::get('detail/:id', 'XxxController/detail'); // 详情
Route::post('add', 'XxxController/add'); // 新增
Route::put('update', 'XxxController/update'); // 修改
Route::delete('delete/:id', 'XxxController/delete'); // 删除
Route::delete('batchDelete', 'XxxController/batchDelete'); // 批量删除
Route::post('import', 'XxxController/import'); // 导入(可选)
Route::get('export', 'XxxController/export'); // 导出(可选)
});
```
:::tip 路由命名规范
- 路径使用小写英文(如 `example`、`user`)
- 详情/删除使用 `:id` 参数(如 `detail/:id`)
- 批量删除使用 `batchDelete` 路径
:::
## 中间件绑定
所有需认证的接口在 `route/app.php` 末尾统一绑定中间件:
```php
})->middleware([
\app\middleware\AuthMiddleware::class, // JWT认证 + 权限校验
\app\middleware\TenantMiddleware::class, // 租户上下文注入
\app\middleware\DemoMiddleware::class, // 演示环境拦截
\app\middleware\LogMiddleware::class, // 操作日志记录
]);
```
## 验证路由注册
```bash
# 查看所有已注册路由
php think route:list
# 测试路由是否正确
curl http://localhost:8000/api/example/page -H "Authorization: Bearer <token>"
```
→ [3.8 参数校验与错误上抛](/zh/guide/validation.html)
+159
View File
@@ -0,0 +1,159 @@
# 3.5 业务层开发(service)
:::tip 概述
service 是业务逻辑的唯一去处。标准五方法(Page/List/Detail/Add/Update/Delete/BatchDelete)通过声明 `ModuleSpec` 委托基座,差异全部进声明字段与钩子;写不进钩子的复杂逻辑手写方法,合法逃生舱。
:::
## 标准结构(三段式)
```go
// internal/service/example.go
package service
// sExample 案例演示业务实现(空结构体仅作方法命名空间,不持有状态)
type sExample struct{}
func init() {
Example = &sExample{}
}
// Example 案例演示业务实例(handler 层引用)
var Example *sExample
```
跨模块调用同包直调,无需 import:`User.Page(...)`、`repository.NewDeptRepo()`。
## ModuleSpec 声明
```go
// internal/service/example.go
var exampleSpec = ModuleSpec[dto.ExampleItem, dto.ExampleItem, dto.ExampleDetailRes]{
Model: func(ctx context.Context) *gorm.DB { return repository.NewExampleRepo().Ctx(ctx) },
LikeFields: []string{"name"}, // name 模糊,空串跳过
EqFields: []string{"type", "status"}, // 等值过滤,零值跳过
OrderBy: "sort, id", // 默认排序
SerializeMaps: exampleSerializeMaps, // status → 字典 example_status 回填 statusText
FileFields: []string{"avatar"}, // 单图:写侧 temp 转正,读侧补域名
FileSaveDir: "example", // 转正到 upload/example
ToData: exampleToData, // req → 萃库 map
}
```
泛型三参数:行结构 `Item`、列表元素 `LItem`(树形时可不同于 Item)、详情结构 `DRes`。
## 查询能力声明(固定顺序生效)
| 声明 | 生效顺序 | 行为 |
|---|---|---|
| Model | 1 | 数据源工厂,必填,通常就是 `NewXxxRepo().Ctx(ctx)` |
| LikeFields | 2 | 逐列按请求同名字段拼 LIKE,空串跳过 |
| EqFields | 3 | 等值过滤,零值跳过(需精确匹配零值的条件写 Filter) |
| KeywordFields | 4 | 请求 keyword 字段对多列 LIKE,括号包成单个 AND |
| Filter | 5 | 复杂条件追加(禁止重复声明前四项已覆盖的条件) |
| DataScope | 6 | 数据权限:`DataScope: ApplyDataScope("dept_id")` |
排序链:请求 `orderField` 命中 `OrderableFields` 白名单时前端排序生效,否则用 `OrderBy`。白名单是排序列注入的唯一防线。
## 输出与写入声明
| 声明 | 行为 |
|---|---|
| SerializeMaps | 键为枚举字段(snake),值为字典编码;读侧自动回填 `{field}Text` |
| FileFields / MultiFileFields | 单文件 / 逗号分隔多文件:写侧 saveTransform 转正,读侧补访问域名 |
| ContentFields | 富文本字段:写侧占位符化,读侧经 ExpandContentFields 还原 |
| IsTree + ParentOf | 树形输出(仅 List 建树;ParentOf 返回 0 表根) |
| UniqueFields | Add/Update 前逐条唯一性预检(并发唯一性仍以数据库 uk 索引为准) |
## ToData:请求转落库字段
```go
// internal/service/example.go
func exampleToData(req any) map[string]any {
switch r := req.(type) {
case *dto.ExampleAddReq:
return map[string]any{"name": r.Name, "avatar": r.Avatar, "type": r.Type, "status": r.Status, "sort": r.Sort}
case *dto.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))
}
```
只映射业务字段——审计字段(create_user/create_time/update_user/update_time/del_flag/tenant_id)由全局钩子统一补齐,**不要在这里赋值**。
## 标准方法(委托基座)
```go
// internal/service/example.go
func (s *sExample) Page(ctx context.Context, req *dto.ExamplePageReq) (*dto.PageData[dto.ExampleItem], error) {
list, total, err := exampleSpec.Page(ctx, req, req.GetPageNo(), req.GetPageSize())
if err != nil {
return nil, err
}
return dto.NewPageData(req.GetPageNo(), req.GetPageSize(), total, derefSlice(list)), nil
}
func (s *sExample) Add(ctx context.Context, req *dto.ExampleAddReq) (*dto.ExampleIdRes, error) {
id, err := exampleSpec.Add(ctx, req)
if err != nil {
return nil, err
}
return &dto.ExampleIdRes{Id: id}, nil
}
func (s *sExample) Update(ctx context.Context, req *dto.ExampleUpdateReq) (*dto.EmptyRes, error) {
if err := exampleSpec.Update(ctx, req.Id, req); err != nil {
return nil, err
}
return &dto.EmptyRes{}, nil
}
func (s *sExample) Delete(ctx context.Context, req *dto.ExampleDeleteReq) (*dto.EmptyRes, error) {
if err := exampleSpec.Delete(ctx, req.Id); err != nil {
return nil, err
}
return &dto.EmptyRes{}, nil
}
```
## 手写方法(复杂逻辑)
标准方法覆盖不了的场景直接手写——这是合法逃生舱,不是妥协:
```go
// internal/service/example.go
// Status 手写单列更新(仅更新 status 一列)
func (s *sExample) Status(ctx context.Context, req *dto.ExampleStatusReq) (*dto.EmptyRes, error) {
if err := repository.NewExampleRepo().Ctx(ctx).
Where("id = ?", req.Id).Updates(map[string]any{"status": req.Status}).Error; err != nil {
return nil, err
}
return &dto.EmptyRes{}, nil
}
```
导入导出、登录、分配菜单、状态流转等都属于手写范畴。导出可复用基座过滤器 `spec.applyFilters(ctx, spec.Model(ctx), req)` 与字典查询 `DictGetText(ctx, dictCode, value)`。
## 业务错误上抛
```go
// internal/service/auth.go(实际代码)——哨兵错误直接返回
return nil, consts.CodeTenantDisabled
// internal/service/base.go(实际代码)——唯一性预检的格式化错误
return apperr.Newf(consts.CodeRecordExists.Code(), "%s已存在", label)
```
错误原样上抛,**禁止在钩子与 service 内做 HTTP 响应或文件 IO 之外的越层处理**(钩子还禁止 append 修改切片)。
## 编写检查清单
- [ ] 三段式结构(sXxx / init / var)
- [ ] 标准方法全部委托 ModuleSpec,业务差异进声明与钩子
- [ ] ToData 不映射审计字段
- [ ] 跨模块同包直调,事务用 `db.WithTrans` 且回调内用入参 ctx
- [ ] dict 模块 init 中调用 `SetDictLookup(...)` 注册字典查询(涉及字典的模块)
## 下一步
→ [3.6 接口层开发(handler)](/zh/guide/handler.html)
@@ -0,0 +1,115 @@
# 3.8 参数校验与错误上抛
:::tip 概述
校验在契约层用标签声明,错误在业务层用哨兵错误上抛——两头都不写手工 if。本页说明绑定优先级、校验标签用法与错误传播路径。
:::
## 绑定优先级
请求参数按固定顺序写入,**后写覆盖**:
```text
query(GET 参数) → body(JSON 体) → path(路径参数)
```
最终优先级表现为 **path > body > query**:同名字段以路径参数为准。每个字段同时带 `json`(body)与 `form`(query)标签即可参与两种来源绑定。
绑定引擎的入口在 `router.Bind` 包装函数内,handler 拿到的 req 已完成解析与校验。
## binding / msg 标签
校验读标准 `binding` 标签,错误消息取 `msg` 标签:
```go
// internal/model/dto/example.go
type ExampleDetailReq struct {
Id int64 `json:"id" binding:"required" msg:"缺少ID" dc:"记录ID"`
}
type ExampleBatchDeleteReq struct {
Ids []int64 `json:"ids" binding:"min=1" msg:"请选择要删除的记录" dc:"ID列表"`
}
// internal/model/dto/user.go(多规则 + 多消息示例)
type UserAddReq struct {
Username string `json:"username" binding:"required,min=2,max=30" msg:"请输入用户名;用户名长度需在2~30位"`
Password string `json:"password" binding:"required,min=6,max=32" msg:"请输入密码;密码长度需在6~32位"`
Mobile string `json:"mobile" binding:"omitempty,phone" msg:"手机号格式不正确"`
Email string `json:"email" binding:"omitempty,email" msg:"邮箱格式不正确"`
}
```
常用规则:
| 标签 | 含义 | 示例 |
|---|---|---|
| required | 必填(零值也算缺失) | `binding:"required"` |
| min / max | 数值/长度边界 | `binding:"min=1" msg:"请选择记录"` |
| oneof | 枚举取值 | `binding:"oneof=1 2"` |
| gte / lte | 大于等于 / 小于等于 | `binding:"gte=1,lte=500"` |
| omitempty | 空值放行(配合格式规则) | `binding:"omitempty,email"` |
| phone / email | 内置格式规则(本项目 validator 扩展) | `binding:"omitempty,phone"` |
**多消息映射**(utility/validator):`msg` 以 `;` 分段与 `binding` 多规则**一一对应**(如 `required,min=2,max=30` ↔ `请输入用户名;用户名长度需在2~30位`);只写一段时适用于全部规则;分段缺失按规则默认提示兜底。校验**快速失败**:返回第一条错误,消息含字段提示语。内嵌结构体(PageReq)由引擎递归校验。
校验失败时框架直接以统一信封返回,msg 即上述文案——**不需要 handler 做任何判断**。
## UnmarshalBody 逃生舱
文件上传等非 JSON 载荷,结构体实现 `UnmarshalBody(c *gin.Context) error` 即可接管解析:
```go
// internal/model/dto/example.go
func (r *ExampleImportReq) UnmarshalBody(c *gin.Context) error {
f, err := c.FormFile("file")
if err != nil {
return errors.New("请选择文件")
}
r.File = f
return nil
}
```
返回 error 即校验失败,同样走统一错误信封。
## 业务错误上抛
service / 钩子内业务失败一律返回 apperr 错误或哨兵错误,**原样上抛,不做包装**:
```go
// internal/service/auth.go(实际代码)
return nil, consts.CodeCaptchaError
return nil, consts.CodeUserDisabled
// internal/service/category.go(实际代码,格式化消息)
return apperr.Newf(consts.CodeParamError.Code(), "上级不能选择自己")
```
response 中间件捕获 error 后:
```text
apperr 错误 → 按其 code/msg 直接输出
普通 error → 映射为通用失败码,msg 取错误文本
panic → recovery 捕获,输出 500 信封并打错误栈日志
```
## 错误处理禁则
| 禁则 | 原因 |
|---|---|
| 在 handler 转发 HTTP 状态码 | 统一信封由 response 中间件负责 |
| 吞掉错误只记日志 | 上层与前端失去失败感知 |
| 包装后重复加前缀 | 钩子与 service 禁止 wrap,错误原样上抛 |
| 用 error 做流程控制 | 分支结果用返回值表达,error 只表示失败 |
## 编写检查清单
- [ ] 必填字段有 required + msg
- [ ] 零值合法的字段不加 required(等值过滤靠 EqFields 零值跳过)
- [ ] 文件字段实现 UnmarshalBody
- [ ] 业务失败返回哨兵/apperr,不 fmt.Errorf 包装
- [ ] 不在 handler 手写参数判断
## 下一步
→ [3.9 权限节点配置](/zh/guide/permission.html)
@@ -1,174 +0,0 @@
# 3.4 验证器开发
:::tip 概述
验证器用于校验请求参数的合法性,继承 ThinkPHP 的 Validate 类。通过在 Logic 层配置 `$validateClass` 属性,`add`/`update` 时自动触发校验,无需手动调用。
:::
## 验证器文件位置
```text
app/validate/ExampleValidate.php
```
## 完整代码(来自项目实际代码)
```php
<?php
declare(strict_types=1);
namespace app\validate;
use think\Validate;
/**
* 案例演示验证器
*
* 负责案例演示模块的入参校验,继承 ThinkPHP 的 Validate。
*
* 结构说明:
* - $rule:字段校验规则,由必填字段自动推导;
* - $message:自定义错误提示,按"字段.规则"生成中文提示;
* - $scene:场景化校验,add 与 update 使用相同的字段集。
*
* 说明:唯一性校验未写入本验证器,
* 由 ExampleLogic 的 uniqueFields 在逻辑层完成。
*/
class ExampleValidate extends Validate
{
/**
* 校验规则
*
* 由生成器按字段的必填属性自动推导,
* 通常形如 '字段名' => 'require'。
*
* @var array
*/
protected $rule = [
'name' => 'require',
];
/**
* 自定义错误提示
*
* 键为"字段名.规则名",值为中文提示语,
* 覆盖框架默认提示,提升用户可读性。
*
* @var array
*/
protected $message = [
'name.require' => '请输入案例名称',
];
/**
* 校验场景
*
* add 与 update 使用相同的字段集,
* 与项目其它验证器的统一模式保持一致。
*
* @var array
*/
protected $scene = [
'add' => ['name'], // 新增场景
'update' => ['name'], // 修改场景
];
}
```
## 在 Logic 中配置自动验证(推荐)
在 Logic 子类中配置 `$validateClass` 属性,`add`/`update` 时自动调用对应验证器:
```php
// app/logic/ExampleLogic.php
class ExampleLogic extends BaseLogic
{
/**
* 关联的模型类
*
* @var string
*/
protected string $modelClass = Example::class;
/**
* 参数验证器类
*
* 配置后,add/update 自动校验
*
* @var string
*/
protected string $validateClass = \app\validate\ExampleValidate::class;
// ... 其他属性
}
```
**工作原理:**
1. `add()` 流程:`beforeAdd` → `validateData($data, 'add')` → 后续入库
2. `update()` 流程:`beforeUpdate` → `validateData($data, 'update', $id)` → 后续入库
3. 验证器按场景(`add`/`update`)校验,场景不存在时使用默认规则
4. 校验失败抛出 `ValidateException`,由 `ExceptionHandle` 统一返回错误响应
**覆盖范围:** 无论是 Controller 通过 `_add`/`_edit` 调用,还是 `import` 等内部调用都会生效。
## 在 Controller 中手动调用(特殊场景)
对于不走 `BaseLogic.add()`/`update()` 的特殊接口(如登录),可在 Controller 中手动调用:
```php
// app/controller/LoginController.php
public function login(): Json
{
$params = $this->getParams();
try {
$this->validate($params, LoginValidate::class . '.login');
} catch (\Exception $e) {
return $this->fail($e->getMessage(), 422);
}
// ... 登录逻辑
}
```
## 常用验证规则
| 规则 | 说明 | 示例 |
|------|------|------|
| `require` | 必填 | `'name' => 'require'` |
| `max:n` | 最大长度 | `'title' => 'max:200'` |
| `min:n` | 最小长度 | `'password' => 'min:6'` |
| `number` | 数字 | `'sort' => 'number'` |
| `integer` | 整数 | `'age' => 'integer'` |
| `in:a,b,c` | 枚举值 | `'status' => 'in:0,1'` |
| `email` | 邮箱格式 | `'email' => 'email'` |
| `mobile` | 手机号格式 | `'mobile' => 'mobile'` |
| `unique:table` | 唯一性 | `'username' => 'unique:user'` |
## 场景(Scene)用法
场景允许在不同接口使用不同的验证规则。项目统一使用 `$scene` 属性定义:
```php
class UserValidate extends Validate
{
protected $rule = [
'username' => 'require|unique:user',
'realname' => 'require',
'mobile' => 'mobile',
'email' => 'email',
];
protected $message = [
'username.require' => '用户名不能为空',
'username.unique' => '用户名已存在',
'realname.require' => '姓名不能为空',
];
// 场景属性:指定每个场景校验哪些字段
protected $scene = [
'add' => ['username', 'realname', 'mobile', 'email'],
'update' => ['realname', 'mobile', 'email'], // 修改时不校验 username
];
}
```
**自动场景匹配:** Logic 配置了 `$validateClass` 后,`add()` 自动使用 `add` 场景,`update()` 自动使用 `update` 场景,无需手动指定。