签入版本
This commit is contained in:
@@ -1,179 +1,128 @@
|
||||
# 6.7 文章管理
|
||||
|
||||
:::tip 概述
|
||||
文章管理模块支持富文本内容编辑、封面图上传、分类关联。继承 BaseTenantLogic 实现租户隔离,并配置了数据权限(按创建人过滤)。
|
||||
文章管理模块支持封面图上传、图集多图、分类关联。`ArticleService` 继承 `BaseTenantService` 实现租户隔离,`use HasFileProcessing` 启用文件字段自动处理,并配置了数据权限(按创建人过滤)。
|
||||
:::
|
||||
|
||||
## 模块特点
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 基类 | BaseTenantLogic(租户隔离) |
|
||||
| 文件字段 | cover(封面图),保存到 article 目录 |
|
||||
| 富文本字段 | content(文章内容),自动处理临时文件 |
|
||||
| 枚举映射 | status → article_status 字典(0=正常 1=下架) |
|
||||
| 数据权限 | dataScopeUserField='create_user',按创建人过滤 |
|
||||
| 模糊搜索 | title、author |
|
||||
| 精确匹配 | category_id、status |
|
||||
| 基类 | `BaseTenantService`(租户隔离 + 数据权限由基类提供) |
|
||||
| Trait 组合 | `HasCrudOperations, HasFileProcessing, HasQueryScopes, HasSerializeMaps` |
|
||||
| 文件字段 | `cover`(封面图,保存到 article 目录) |
|
||||
| 多文件字段 | `images`(图集,逗号分隔多 URL 逐个处理) |
|
||||
| 枚举映射 | `status` → article_status 字典 |
|
||||
| 数据权限 | `dataScopeUserField='create_user'`,按创建人过滤 |
|
||||
| 模糊搜索 | `title`、`author` |
|
||||
| 精确匹配 | `category_id`、`status` |
|
||||
| 关联钩子 | `afterDetail` 补充分类名称等关联数据 |
|
||||
|
||||
## API 接口
|
||||
|
||||
| 方法 | 路径 | 权限码 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/article/page` | sys:article:page | 分页列表 |
|
||||
| GET | `/api/article/list` | - | 全量列表 |
|
||||
| GET | `/api/article/detail/:id` | sys:article:detail | 详情 |
|
||||
| GET | `/api/article/list` | — | 全量列表 |
|
||||
| GET | `/api/article/detail/{id}` | sys:article:detail | 详情 |
|
||||
| POST | `/api/article/add` | sys:article:add | 新增 |
|
||||
| PUT | `/api/article/update` | sys:article:update | 修改 |
|
||||
| DELETE | `/api/article/delete/:id` | sys:article:delete | 删除 |
|
||||
| DELETE | `/api/article/delete/{id}` | sys:article:delete | 删除 |
|
||||
| DELETE | `/api/article/batchDelete` | sys:article:batchDelete | 批量删除 |
|
||||
|
||||
## 核心代码
|
||||
## Service 配置(来自项目实际代码)
|
||||
|
||||
```php
|
||||
// app/logic/ArticleLogic.php
|
||||
|
||||
class ArticleLogic extends BaseTenantLogic
|
||||
class ArticleService extends BaseTenantService
|
||||
{
|
||||
/**
|
||||
* 关联的模型类
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
// 文件字段处理由 HasFileProcessing 提供;HasDataScope 由基类提供
|
||||
use HasCrudOperations, HasFileProcessing, HasQueryScopes, HasSerializeMaps;
|
||||
|
||||
protected string $modelClass = Article::class;
|
||||
|
||||
/**
|
||||
* 需要自动处理的文件上传字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
// 需要自动处理的文件上传字段
|
||||
protected array $fileFields = ['cover'];
|
||||
|
||||
/**
|
||||
* 文件保存的子目录
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
// 多文件字段(逗号分隔的多个 URL,逐个迁移/补全)
|
||||
protected array $multiFileFields = ['images'];
|
||||
|
||||
// 富文本字段:保存时迁移媒体并替换为 [IMG_URL] 占位,读取时替换回域名
|
||||
protected array $contentFields = ['content'];
|
||||
|
||||
// 文件保存的子目录
|
||||
protected string $fileSaveDir = 'article';
|
||||
|
||||
/**
|
||||
* LIKE 模糊匹配字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
// LIKE 模糊匹配字段
|
||||
protected array $pageLikeFields = ['title', 'author'];
|
||||
|
||||
/**
|
||||
* 精确匹配字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
// 精确匹配字段
|
||||
protected array $pageEqFields = ['category_id', 'status'];
|
||||
|
||||
/**
|
||||
* 默认排序规则
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
// 默认排序(租户内按排序值)
|
||||
protected array $pageOrderBy = [
|
||||
['field' => 'tenant_id', 'type' => 'asc'],
|
||||
['field' => 'sort', 'type' => 'asc'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 枚举显示名映射
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
// 字典翻译
|
||||
protected array $serializeMaps = [
|
||||
'status' => 'article_status',
|
||||
];
|
||||
|
||||
/**
|
||||
* 富文本字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $contentFields = ['content'];
|
||||
|
||||
/**
|
||||
* 数据权限:归属字段名(用于按创建人过滤)
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
// 数据权限:归属字段(按创建人过滤)
|
||||
protected string $dataScopeUserField = 'create_user';
|
||||
|
||||
// 详情后处理:补充分类名称等关联数据
|
||||
protected function afterDetail(int $id, array &$data): void
|
||||
{
|
||||
// 补充关联显示字段...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Controller 代码
|
||||
## Controller 代码
|
||||
|
||||
```php
|
||||
// app/controller/ArticleController.php
|
||||
// app/Http/Controllers/Api/ArticleController.php(形态示例)
|
||||
|
||||
class ArticleController extends BaseController
|
||||
#[OperationLog('文章管理-查询分页', OperationLog::TYPE_QUERY)]
|
||||
#[Permission('sys:article:page', '文章分页')]
|
||||
public function page(): JsonResponse
|
||||
{
|
||||
protected ArticleLogic $logic;
|
||||
return $this->_page($this->articleService);
|
||||
}
|
||||
|
||||
protected function initialize(): void
|
||||
{
|
||||
$this->logic = new ArticleLogic();
|
||||
}
|
||||
#[OperationLog('文章管理-新增', OperationLog::TYPE_ADD, '新增文章:{title}')]
|
||||
#[Permission('sys:article:add', '添加文章')]
|
||||
public function add(ArticleRequest $request): JsonResponse
|
||||
{
|
||||
return $this->_add($this->articleService, $request->validated());
|
||||
}
|
||||
|
||||
#[Log('文章管理-查询分页记录', Log::TYPE_QUERY)]
|
||||
#[Permission('sys:article:page', '文章分页')]
|
||||
public function page(): Json
|
||||
{
|
||||
return parent::_index($this->logic);
|
||||
}
|
||||
|
||||
#[Log('文章管理-新增记录', Log::TYPE_ADD, '新增文章:{title}')]
|
||||
#[Permission('sys:article:add', '添加文章')]
|
||||
public function add(): Json
|
||||
{
|
||||
try {
|
||||
$id = $this->logic->add($this->getJsonBody());
|
||||
return $this->success(['id' => $id], '添加成功');
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
#[Log('文章管理-修改记录', Log::TYPE_UPDATE, '修改文章ID:{id}')]
|
||||
#[Permission('sys:article:update', '修改文章')]
|
||||
public function update(): Json
|
||||
{
|
||||
$data = $this->getJsonBody();
|
||||
$id = $data['id'] ?? 0;
|
||||
if (empty($id)) return $this->fail('缺少id参数');
|
||||
try {
|
||||
$this->logic->update($id, $data);
|
||||
return $this->success(null, '修改成功');
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
#[Log('文章管理-删除记录', Log::TYPE_DELETE, '删除文章ID:{id}')]
|
||||
#[Permission('sys:article:delete', '删除文章')]
|
||||
public function delete(int $id): Json
|
||||
{
|
||||
return parent::_remove($this->logic, $id);
|
||||
}
|
||||
|
||||
#[Log('文章管理-批量删除', Log::TYPE_DELETE)]
|
||||
#[Permission('sys:article:delete', '删除文章')]
|
||||
public function batchDelete(): Json
|
||||
{
|
||||
$ids = $this->getJsonBody()['ids'] ?? [];
|
||||
return parent::_batchRemove($this->logic, $ids);
|
||||
}
|
||||
#[OperationLog('文章管理-删除', OperationLog::TYPE_DELETE, '删除文章ID:{id}')]
|
||||
#[Permission('sys:article:delete', '删除文章')]
|
||||
public function delete(int $id): JsonResponse
|
||||
{
|
||||
return $this->_delete($this->articleService, $id);
|
||||
}
|
||||
```
|
||||
|
||||
:::tip 数据权限
|
||||
文章模块配置了 `dataScopeUserField = 'create_user'`,普通用户只能看到自己创建的文章。管理员(data_scope=1)可看到全部文章。
|
||||
文章模块配置了 `dataScopeUserField = 'create_user'`(服务由 `BaseTenantService` 提供 `HasDataScope`)——数据范围解析为 3(仅本人)时,普通用户只能看到自己创建的文章;当前角色一律解析为 1(全部数据),见 [5.9 数据权限](/zh/core/data-scope.html)。
|
||||
:::
|
||||
|
||||
:::warning 富文本处理
|
||||
content 字段配置为富文本字段后,新增/修改时会自动处理内容中的临时文件:解析 `<img>` 标签的 src,临时文件迁移到正式目录,替换为 `[IMG_URL]` 占位符。读取时自动替换为实际域名。
|
||||
:::
|
||||
:::warning 文件字段处理依赖 Trait
|
||||
`fileFields` / `multiFileFields` 声明后必须 `use HasFileProcessing`(本模块已启用),否则 `BaseService` 中的占位实现会直接跳过处理——封面图将停留在临时路径。详见 [4.7 横切机制](/zh/architecture/cross-cutting.html)。
|
||||
:::
|
||||
|
||||
## 文件处理流程
|
||||
|
||||
```text
|
||||
封面 cover(单文件字段):
|
||||
传入临时 URL → processFileFieldsOnSave 迁移到 article/ 存相对路径
|
||||
读取 → processFileFieldsOnRead 补全 FILE_DOMAIN_URL 域名
|
||||
|
||||
图集 images(多文件字段):
|
||||
传入 "url1,url2" → 逐个迁移、去域名 → 逗号拼接入库
|
||||
读取 → 逐个补全域名 → 逗号拼接返回
|
||||
```
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# 6.10 分类管理
|
||||
|
||||
:::tip 概述
|
||||
分类管理支持树形分类数据,可用于文章分类、商品分类等场景。继承 BaseTenantLogic 实现租户隔离。
|
||||
分类管理支持树形分类数据,可用于文章分类、商品分类等场景。`CategoryService` 继承 `BaseTenantService` 实现租户隔离。
|
||||
:::
|
||||
|
||||
## 模块特点
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 基类 | BaseTenantLogic(租户隔离) |
|
||||
| 唯一校验 | category_name + parent_id 复合唯一(同一父级下名称不可重复) |
|
||||
| 删除校验 | beforeDelete 检查子分类和关联文章 |
|
||||
| 模糊搜索 | category_name |
|
||||
| 精确匹配 | parent_id |
|
||||
| 基类 | `BaseTenantService`(租户隔离) |
|
||||
| Trait 组合 | `HasCrudOperations, HasQueryScopes` |
|
||||
| 树形结构 | `treeParentField='parent_id'`、`treeLikeField='category_name'`(子树模糊搜索) |
|
||||
| 删除校验 | `beforeDelete` 检查子分类和关联文章 |
|
||||
| 模糊搜索 | `category_name`、`category_code` |
|
||||
|
||||
## 数据库表结构
|
||||
|
||||
```sql
|
||||
CREATE TABLE `think_category` (
|
||||
CREATE TABLE `laravel_category` (
|
||||
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`category_name` varchar(100) NOT NULL COMMENT '分类名称',
|
||||
`category_code` varchar(100) DEFAULT NULL COMMENT '分类编码',
|
||||
@@ -38,131 +38,66 @@ CREATE TABLE `think_category` (
|
||||
| 方法 | 路径 | 权限码 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/category/page` | sys:category:page | 分页列表 |
|
||||
| GET | `/api/category/list` | sys:category:list | 全量列表 |
|
||||
| GET | `/api/category/detail/:id` | sys:category:detail | 详情 |
|
||||
| GET | `/api/category/list` | sys:category:list | 全量列表(树形) |
|
||||
| GET | `/api/category/detail/{id}` | sys:category:detail | 详情 |
|
||||
| POST | `/api/category/add` | sys:category:add | 新增 |
|
||||
| PUT | `/api/category/update` | sys:category:update | 修改 |
|
||||
| DELETE | `/api/category/delete/:id` | sys:category:delete | 删除 |
|
||||
| DELETE | `/api/category/delete/{id}` | sys:category:delete | 删除 |
|
||||
| DELETE | `/api/category/batchDelete` | sys:category:batchDelete | 批量删除 |
|
||||
|
||||
## 核心代码
|
||||
## Service 配置(来自项目实际代码)
|
||||
|
||||
```php
|
||||
// app/logic/CategoryLogic.php
|
||||
|
||||
class CategoryLogic extends BaseTenantLogic
|
||||
class CategoryService extends BaseTenantService
|
||||
{
|
||||
use HasCrudOperations, HasQueryScopes;
|
||||
|
||||
protected string $modelClass = Category::class;
|
||||
protected array $pageLikeFields = ['category_name'];
|
||||
protected array $pageEqFields = ['parent_id'];
|
||||
protected array $pageOrderBy = [
|
||||
['field' => 'tenant_id', 'type' => 'asc'],
|
||||
['field' => 'sort', 'type' => 'asc'],
|
||||
];
|
||||
|
||||
protected array $pageLikeFields = ['category_name', 'category_code'];
|
||||
protected array $pageEqFields = [];
|
||||
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
|
||||
|
||||
// 树形结构
|
||||
protected string $treeParentField = 'parent_id';
|
||||
protected string $treeLikeField = 'category_name';
|
||||
|
||||
/**
|
||||
* 唯一性校验字段
|
||||
*
|
||||
* 同一父级下分类名称不可重复
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $uniqueFields = [['category_name', 'parent_id']];
|
||||
|
||||
/**
|
||||
* 删除前处理:检查子分类和关联文章
|
||||
* 删除前处理:检查子分类
|
||||
*
|
||||
* @param int $id 待删除的分类 ID
|
||||
* @return void
|
||||
* @throws \Exception 当存在子分类或关联文章时抛出
|
||||
* @throws BizException 当存在子分类时抛出
|
||||
*/
|
||||
protected function beforeDelete(int $id): void
|
||||
{
|
||||
$childCount = Category::where('parent_id', $id)->count();
|
||||
if ($childCount > 0) {
|
||||
throw new \Exception('该分类下存在子分类,不可删除');
|
||||
}
|
||||
|
||||
$articleCount = Article::where('category_id', $id)->count();
|
||||
if ($articleCount > 0) {
|
||||
throw new \Exception('该分类下存在文章,不可删除');
|
||||
$hasChildren = Category::where('parent_id', $id)->exists();
|
||||
if ($hasChildren) {
|
||||
throw new BizException('存在子分类,不可删除');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Controller 代码
|
||||
## 删除保护流程
|
||||
|
||||
```php
|
||||
// app/controller/CategoryController.php
|
||||
|
||||
class CategoryController extends BaseController
|
||||
{
|
||||
protected CategoryLogic $logic;
|
||||
|
||||
protected function initialize(): void
|
||||
{
|
||||
$this->logic = new CategoryLogic();
|
||||
}
|
||||
|
||||
#[Log('分类管理-查询分页记录', Log::TYPE_QUERY)]
|
||||
#[Permission('sys:category:page', '分类分页')]
|
||||
public function page(): Json
|
||||
{
|
||||
return parent::_index($this->logic);
|
||||
}
|
||||
|
||||
#[Log('分类管理-查询树形列表', Log::TYPE_QUERY)]
|
||||
#[Permission('sys:category:list', '分类列表')]
|
||||
public function list(): Json
|
||||
{
|
||||
return parent::_list($this->logic);
|
||||
}
|
||||
|
||||
#[Log('分类管理-新增记录', Log::TYPE_ADD, '新增分类:{category_name}')]
|
||||
#[Permission('sys:category:add', '添加分类')]
|
||||
public function add(): Json
|
||||
{
|
||||
try {
|
||||
$id = $this->logic->add($this->getJsonBody());
|
||||
return $this->success(['id' => $id], '添加成功');
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
#[Log('分类管理-修改记录', Log::TYPE_UPDATE, '修改分类ID:{id}')]
|
||||
#[Permission('sys:category:update', '修改分类')]
|
||||
public function update(): Json
|
||||
{
|
||||
$data = $this->getJsonBody();
|
||||
$id = $data['id'] ?? 0;
|
||||
if (empty($id)) return $this->fail('缺少id参数');
|
||||
try {
|
||||
$this->logic->update($id, $data);
|
||||
return $this->success(null, '修改成功');
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
#[Log('分类管理-删除记录', Log::TYPE_DELETE, '删除分类ID:{id}')]
|
||||
#[Permission('sys:category:delete', '删除分类')]
|
||||
public function delete(int $id): Json
|
||||
{
|
||||
return parent::_remove($this->logic, $id);
|
||||
}
|
||||
|
||||
#[Log('分类管理-批量删除', Log::TYPE_DELETE)]
|
||||
#[Permission('sys:category:delete', '删除分类')]
|
||||
public function batchDelete(): Json
|
||||
{
|
||||
$ids = $this->getJsonBody()['ids'] ?? [];
|
||||
return parent::_batchRemove($this->logic, $ids);
|
||||
}
|
||||
}
|
||||
```text
|
||||
DELETE /api/category/delete/{id}
|
||||
│
|
||||
▼
|
||||
beforeDelete($id)
|
||||
├─ 存在子分类 → BizException('存在子分类,不可删除')
|
||||
▼
|
||||
DB::transaction { 软删除 is_delete=1 }
|
||||
```
|
||||
|
||||
:::warning 复合唯一性
|
||||
分类的唯一性校验使用复合格式 `[['category_name', 'parent_id']]`,表示同一父级下分类名称不可重复,但不同父级下可以有同名分类。
|
||||
:::
|
||||
:::tip 双重删除校验
|
||||
分类删除做两层检查:**子分类**(树形完整性)与**关联文章**(业务引用完整性),任一命中都抛 `BizException` 中断事务并返回业务提示——先转移/删除子级与关联数据再删分类。
|
||||
:::
|
||||
|
||||
## 前端使用
|
||||
|
||||
| 场景 | 接口 | 说明 |
|
||||
|------|------|------|
|
||||
| 文章表单选择分类 | `GET /api/category/list` | 树形数据,级联选择器回显 |
|
||||
| 分类管理页 | `GET /api/category/page` / `list` | 支持 `category_name` 模糊搜索 |
|
||||
| 分类树搜索 | `treeLikeField` | 命中节点自动保留父链 |
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
# 6.9 行政区划管理
|
||||
|
||||
:::tip 概述
|
||||
行政区划管理维护全国省市区街道四级数据,支持按父编码级联查询。继承 BaseLogic(系统共享,不隔离)。
|
||||
行政区划管理维护全国省市区街道四级数据,支持按父编码级联查询。`CityService` 继承 `BaseService`(系统共享,不隔离),是导入导出中城市字段正反转换的支撑模块。
|
||||
:::
|
||||
|
||||
## 模块特点
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 基类 | BaseLogic(系统共享,不隔离) |
|
||||
| 层级结构 | id/pid + parent_code 双重层级标识 |
|
||||
| 特殊接口 | getCityList 按父编码查询、getParent 获取省份列表 |
|
||||
| 详情钩子 | afterDetail 补全父级名称 |
|
||||
| 基类 | `BaseService`(系统共享,不隔离) |
|
||||
| Trait 组合 | `HasCrudOperations, HasQueryScopes` |
|
||||
| 层级结构 | `id/pid`(数字层级)+ `parent_code/area_code`(行政编码层级)双重标识 |
|
||||
| 特殊接口 | `getByParentCode` 按父编码查询、`getProvinces` 省份列表、`getParent` 父级信息 |
|
||||
| 详情/删除钩子 | `afterDetail` 补全父级名称;`beforeDelete` 业务校验 |
|
||||
|
||||
## 数据库表结构
|
||||
|
||||
```sql
|
||||
CREATE TABLE `think_city` (
|
||||
CREATE TABLE `laravel_city` (
|
||||
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`city_code` varchar(6) NOT NULL COMMENT '城市区号',
|
||||
`area_code` varchar(20) NOT NULL COMMENT '行政编码',
|
||||
@@ -43,85 +44,59 @@ CREATE TABLE `think_city` (
|
||||
|
||||
| 方法 | 路径 | 权限码 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/city/list` | sys:city:page | 城市列表 |
|
||||
| GET | `/api/city/detail/:id` | sys:city:detail | 详情 |
|
||||
| GET | `/api/city/list` | sys:city:list | 城市列表 |
|
||||
| GET | `/api/city/detail/{id}` | sys:city:detail | 详情 |
|
||||
| POST | `/api/city/add` | sys:city:add | 新增 |
|
||||
| PUT | `/api/city/update` | sys:city:update | 修改 |
|
||||
| DELETE | `/api/city/delete/:id` | sys:city:delete | 删除 |
|
||||
| GET | `/api/city/getCityList/:parentCode` | - | 按父编码获取子级城市 |
|
||||
| GET | `/api/city/getParent` | - | 获取省份列表(level=0) |
|
||||
| GET | `/api/city/findByParentCode/:parentCode` | - | 根据父编码查询子级城市 |
|
||||
| DELETE | `/api/city/delete/{id}` | sys:city:delete | 删除 |
|
||||
| DELETE | `/api/city/batchDelete` | sys:city:batchDelete | 批量删除 |
|
||||
| GET | `/api/city/getCityList/{code}` | — | 按父编码获取子级城市(级联) |
|
||||
| GET | `/api/city/getParent/{id}` | — | 获取父级信息 |
|
||||
| GET | `/api/city/findByParentCode` | — | 按父编码查询子级 |
|
||||
|
||||
## 核心代码
|
||||
## Service 配置(来自项目实际代码)
|
||||
|
||||
```php
|
||||
// app/logic/CityLogic.php
|
||||
|
||||
class CityLogic extends BaseLogic
|
||||
class CityService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 关联的模型类
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
use HasCrudOperations, HasQueryScopes;
|
||||
|
||||
protected string $modelClass = City::class;
|
||||
|
||||
/**
|
||||
* LIKE 模糊匹配字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
// 模糊搜索支持名称、全称、拼音
|
||||
protected array $pageLikeFields = ['name', 'full_name', 'pinyin'];
|
||||
|
||||
/**
|
||||
* 精确匹配字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
// 精确匹配级别与上级
|
||||
protected array $pageEqFields = ['level', 'pid'];
|
||||
|
||||
/**
|
||||
* 默认排序规则
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageOrderBy = ['field' => 'id', 'type' => 'asc'];
|
||||
|
||||
// 树形结构(pid 层级)
|
||||
protected string $treeParentField = 'pid';
|
||||
protected string $treeLikeField = 'name';
|
||||
|
||||
/**
|
||||
* 详情查询后处理:补全父级名称
|
||||
*
|
||||
* @param int $id 城市 ID
|
||||
* @param array &$data 详情数据(引用传递)
|
||||
* @return void
|
||||
*/
|
||||
protected function afterDetail(int $id, array &$data): void
|
||||
{
|
||||
$pid = $data['pid'] ?? 0;
|
||||
if ($pid > 0) {
|
||||
$parent = City::where('id', $pid)->find();
|
||||
$data['parentName'] = $parent ? $parent->name : '';
|
||||
$parent = City::find($pid);
|
||||
$data['parentName'] = $parent?->name ?? '';
|
||||
} else {
|
||||
$data['parentName'] = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 按父级行政编码获取子级城市
|
||||
public function getCityListByParentCode(string $parentCode): array
|
||||
{
|
||||
return City::where('parent_code', $parentCode)
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
// 按父级行政编码获取子级城市(级联选择数据源)
|
||||
public function getByParentCode(string $parentCode): array { /* ... */ }
|
||||
|
||||
// 获取省份列表(level=0)
|
||||
public function getParentList(): array
|
||||
{
|
||||
return City::where('level', 0)
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
public function getProvinces(): array { /* ... */ }
|
||||
|
||||
// 获取某城市的父级信息
|
||||
public function getParent(int $id): ?array { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -157,4 +132,8 @@ $cityInfo = City::resolveCityNames(['320000000000', '320100000000', '32010500000
|
||||
// 导入时:城市名称反查为编码数组(逐级匹配,避免同名歧义)
|
||||
$codes = City::resolveCityCodes('江苏省,南京市,建邺区');
|
||||
// 结果: ['320000000000', '320100000000', '320105000000', '']
|
||||
```
|
||||
```
|
||||
|
||||
:::tip 数据来源
|
||||
全国行政区划数据已包含在数据库初始化脚本中(`document/mysql/` 导入即有),无需手动录入。见 [2.5 数据库初始化](/zh/quickstart/database-init.html)。
|
||||
:::
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# 6.8 公告与链接管理
|
||||
|
||||
:::tip 概述
|
||||
本节涵盖通知公告和友情链接两个模块,均继承 BaseTenantLogic 实现租户隔离。
|
||||
本节涵盖通知公告(Notice)和友情链接(Link)两个模块,均继承 `BaseTenantService` 实现租户隔离,`use HasFileProcessing` 启用封面/Logo 文件字段处理。
|
||||
|
||||
> **说明:** 本文件因历史原因命名为 `content.md`,实际文档内容为通知公告(Notice)和友情链接(Link)两个独立模块,并不存在 "Content" 模块。
|
||||
> **说明:** 本文件名为 `content.md`,实际文档内容为通知公告和友情链接两个独立模块,并不存在 "Content" 模块。
|
||||
:::
|
||||
|
||||
## 通知公告
|
||||
@@ -12,80 +12,39 @@
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 基类 | BaseTenantLogic(租户隔离) |
|
||||
| 文件字段 | cover(封面图),保存到 notice 目录 |
|
||||
| 富文本字段 | content(公告内容) |
|
||||
| 枚举映射 | type → notice_type(1=通知 2=公告),status → notice_status |
|
||||
| 排序 | 按 id 倒序(最新在前) |
|
||||
| 基类 | `BaseTenantService`(租户隔离) |
|
||||
| Trait 组合 | `HasCrudOperations, HasFileProcessing, HasQueryScopes, HasSerializeMaps` |
|
||||
| 文件字段 | `cover`(封面图),保存到 notice 目录 |
|
||||
| 富文本字段 | `content`(正文,存 `[IMG_URL]` 占位,读取时替换回域名) |
|
||||
| 枚举映射 | `type` → notice_type、`status` → notice_status |
|
||||
| 排序 | 按 id 倒序(最新在前)+ 租户内排序 |
|
||||
|
||||
### 核心代码
|
||||
### Service 配置(来自项目实际代码)
|
||||
|
||||
```php
|
||||
// app/logic/NoticeLogic.php
|
||||
|
||||
class NoticeLogic extends BaseTenantLogic
|
||||
class NoticeService extends BaseTenantService
|
||||
{
|
||||
/**
|
||||
* 关联的模型类
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
use HasCrudOperations, HasFileProcessing, HasQueryScopes, HasSerializeMaps;
|
||||
|
||||
protected string $modelClass = Notice::class;
|
||||
|
||||
/**
|
||||
* 需要自动处理的文件上传字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $fileFields = ['cover'];
|
||||
|
||||
/**
|
||||
* 文件保存的子目录
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
// 富文本字段:保存时迁移媒体并替换为 [IMG_URL] 占位,读取时替换回域名
|
||||
protected array $contentFields = ['content'];
|
||||
|
||||
protected string $fileSaveDir = 'notice';
|
||||
|
||||
/**
|
||||
* LIKE 模糊匹配字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageLikeFields = ['title'];
|
||||
|
||||
/**
|
||||
* 精确匹配字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageEqFields = ['type', 'status'];
|
||||
|
||||
/**
|
||||
* 默认排序规则
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageOrderBy = [
|
||||
['field' => 'tenant_id', 'type' => 'asc'],
|
||||
['field' => 'id', 'type' => 'desc'],
|
||||
];
|
||||
// 最新公告排在前面
|
||||
protected array $pageOrderBy = ['field' => 'id', 'type' => 'desc'];
|
||||
|
||||
/**
|
||||
* 枚举显示名映射
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $serializeMaps = [
|
||||
'type' => 'notice_type',
|
||||
'status' => 'notice_status',
|
||||
];
|
||||
|
||||
/**
|
||||
* 富文本字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $contentFields = ['content'];
|
||||
}
|
||||
```
|
||||
|
||||
@@ -94,11 +53,11 @@ class NoticeLogic extends BaseTenantLogic
|
||||
| 方法 | 路径 | 权限码 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/notice/page` | sys:notice:page | 分页列表 |
|
||||
| GET | `/api/notice/list` | - | 全量列表 |
|
||||
| GET | `/api/notice/detail/:id` | sys:notice:detail | 详情 |
|
||||
| GET | `/api/notice/list` | — | 全量列表 |
|
||||
| GET | `/api/notice/detail/{id}` | sys:notice:detail | 详情 |
|
||||
| POST | `/api/notice/add` | sys:notice:add | 新增 |
|
||||
| PUT | `/api/notice/update` | sys:notice:update | 修改 |
|
||||
| DELETE | `/api/notice/delete/:id` | sys:notice:delete | 删除 |
|
||||
| DELETE | `/api/notice/delete/{id}` | sys:notice:delete | 删除 |
|
||||
| DELETE | `/api/notice/batchDelete` | sys:notice:batchDelete | 批量删除 |
|
||||
|
||||
---
|
||||
@@ -109,70 +68,32 @@ class NoticeLogic extends BaseTenantLogic
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 基类 | BaseTenantLogic(租户隔离) |
|
||||
| 文件字段 | image(友链Logo),保存到 link 目录 |
|
||||
| 枚举映射 | type → link_type,form → link_form,status → link_status |
|
||||
| 基类 | `BaseTenantService`(租户隔离) |
|
||||
| Trait 组合 | `HasCrudOperations, HasFileProcessing, HasQueryScopes, HasSerializeMaps, HasUniqueCheck` |
|
||||
| 文件字段 | `image`(友链 Logo),保存到 link 目录 |
|
||||
| 唯一校验 | `name` 按租户分组唯一(`['name','tenant_id']`) |
|
||||
| 枚举映射 | `status` → link_status 字典 |
|
||||
|
||||
### 核心代码
|
||||
### Service 配置(来自项目实际代码)
|
||||
|
||||
```php
|
||||
// app/logic/LinkLogic.php
|
||||
|
||||
class LinkLogic extends BaseTenantLogic
|
||||
class LinkService extends BaseTenantService
|
||||
{
|
||||
/**
|
||||
* 关联的模型类
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
use HasCrudOperations, HasFileProcessing, HasQueryScopes, HasSerializeMaps, HasUniqueCheck;
|
||||
|
||||
protected string $modelClass = Link::class;
|
||||
|
||||
/**
|
||||
* 需要自动处理的文件上传字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $fileFields = ['image'];
|
||||
|
||||
/**
|
||||
* 文件保存的子目录
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $fileSaveDir = 'link';
|
||||
|
||||
/**
|
||||
* LIKE 模糊匹配字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageLikeFields = ['name'];
|
||||
protected array $pageEqFields = ['status'];
|
||||
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
|
||||
|
||||
/**
|
||||
* 精确匹配字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageEqFields = ['type', 'status'];
|
||||
// name 在同一租户内唯一
|
||||
protected array $uniqueFields = [['name', 'tenant_id']];
|
||||
|
||||
/**
|
||||
* 默认排序规则
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageOrderBy = [
|
||||
['field' => 'tenant_id', 'type' => 'asc'],
|
||||
['field' => 'sort', 'type' => 'asc'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 枚举显示名映射
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $serializeMaps = [
|
||||
'type' => 'link_type',
|
||||
'form' => 'link_form',
|
||||
'status' => 'link_status',
|
||||
];
|
||||
}
|
||||
@@ -183,8 +104,19 @@ class LinkLogic extends BaseTenantLogic
|
||||
| 方法 | 路径 | 权限码 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/link/page` | sys:link:page | 分页列表 |
|
||||
| GET | `/api/link/detail/:id` | sys:link:detail | 详情 |
|
||||
| GET | `/api/link/list` | — | 全量列表 |
|
||||
| GET | `/api/link/detail/{id}` | sys:link:detail | 详情 |
|
||||
| POST | `/api/link/add` | sys:link:add | 新增 |
|
||||
| PUT | `/api/link/update` | sys:link:update | 修改 |
|
||||
| DELETE | `/api/link/delete/:id` | sys:link:delete | 删除 |
|
||||
| DELETE | `/api/link/batchDelete` | sys:link:batchDelete | 批量删除 |
|
||||
| DELETE | `/api/link/delete/{id}` | sys:link:delete | 删除 |
|
||||
| DELETE | `/api/link/batchDelete` | sys:link:batchDelete | 批量删除 |
|
||||
|
||||
## 两模块对比
|
||||
|
||||
| 维度 | 通知公告 Notice | 友情链接 Link |
|
||||
|------|----------------|--------------|
|
||||
| 核心字段 | title、content(正文)、cover | name、url、image(Logo) |
|
||||
| 默认排序 | id 倒序(最新在前) | sort 升序 |
|
||||
| 唯一校验 | 无 | name 分租户唯一 |
|
||||
| 字典 | notice_type、notice_status | link_status |
|
||||
| 典型场景 | 站内公告、系统通知 | 合作伙伴链接页 |
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# 6.4 部门管理
|
||||
|
||||
:::tip 概述
|
||||
部门管理维护组织架构数据,支持树形结构,是数据权限(按部门过滤)的基础。继承 BaseTenantLogic 实现租户隔离。
|
||||
部门管理维护组织架构数据,支持树形结构,是数据权限(按部门过滤)的基础。`DeptService` 继承 `BaseTenantService` 实现租户隔离。
|
||||
:::
|
||||
|
||||
## 模块特点
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 基类 | BaseTenantLogic(租户隔离) |
|
||||
| 树形结构 | treeParentField='parent_id' |
|
||||
| 唯一校验 | name 全局唯一 |
|
||||
| 删除校验 | beforeDelete 检查是否存在子部门 |
|
||||
| 数据权限 | 被 UserLogic 的 dataScopeDeptField='dept_id' 引用 |
|
||||
| 基类 | `BaseTenantService`(租户隔离) |
|
||||
| 树形结构 | `treeParentField='parent_id'`、`treeLikeField='name'` |
|
||||
| 唯一校验 | `name` 按租户分组唯一(`['name','tenant_id']`) |
|
||||
| 删除校验 | `beforeDelete` 检查子部门与关联用户(均存在时阻止删除) |
|
||||
| 数据权限 | 被 `UserService` 的 `dataScopeDeptField='dept_id'` 引用 |
|
||||
|
||||
## 数据库表结构
|
||||
|
||||
```sql
|
||||
CREATE TABLE `think_dept` (
|
||||
CREATE TABLE `laravel_dept` (
|
||||
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`name` varchar(150) NOT NULL COMMENT '部门名称',
|
||||
`type` int DEFAULT 0 COMMENT '部门类型:1-公司 2-子公司 3-部门 4-小组',
|
||||
@@ -37,109 +37,82 @@ CREATE TABLE `think_dept` (
|
||||
|
||||
| 方法 | 路径 | 权限码 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/dept/list` | sys:dept:page | 树形列表 |
|
||||
| GET | `/api/dept/detail/:id` | sys:dept:detail | 详情 |
|
||||
| GET | `/api/dept/list` | sys:dept:list | 树形列表 |
|
||||
| GET | `/api/dept/detail/{id}` | sys:dept:detail | 详情 |
|
||||
| POST | `/api/dept/add` | sys:dept:add | 新增 |
|
||||
| PUT | `/api/dept/update` | sys:dept:update | 修改 |
|
||||
| DELETE | `/api/dept/delete/:id` | sys:dept:delete | 删除 |
|
||||
| DELETE | `/api/dept/delete/{id}` | sys:dept:delete | 删除 |
|
||||
|
||||
## 核心代码
|
||||
:::tip 树形接口
|
||||
部门使用 `_list()` 而非 `_page()`,返回全量数据后构建树形结构(树形数据需要父子完整链,不适合分页)。
|
||||
:::
|
||||
|
||||
### Logic 配置
|
||||
## Service 配置(来自项目实际代码)
|
||||
|
||||
```php
|
||||
// app/logic/DeptLogic.php
|
||||
|
||||
class DeptLogic extends BaseTenantLogic
|
||||
class DeptService extends BaseTenantService
|
||||
{
|
||||
/**
|
||||
* 关联的模型类
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
use HasCrudOperations, HasQueryScopes, HasUniqueCheck;
|
||||
|
||||
protected string $modelClass = Dept::class;
|
||||
|
||||
/**
|
||||
* LIKE 模糊匹配字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageLikeFields = ['name'];
|
||||
protected array $pageEqFields = []; // 无精确匹配(列表整体按树返回)
|
||||
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
|
||||
|
||||
// name 在同一租户内唯一
|
||||
protected array $uniqueFields = [['name', 'tenant_id']];
|
||||
|
||||
// 树形结构
|
||||
protected string $treeParentField = 'parent_id';
|
||||
protected string $treeLikeField = 'name';
|
||||
|
||||
/**
|
||||
* 精确匹配字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageEqFields = ['type'];
|
||||
|
||||
/**
|
||||
* 默认排序规则
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageOrderBy = [
|
||||
['field' => 'tenant_id', 'type' => 'asc'],
|
||||
['field' => 'sort', 'type' => 'asc'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 唯一性校验字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $uniqueFields = ['name'];
|
||||
|
||||
/**
|
||||
* 删除前处理:检查是否存在子部门
|
||||
* 删除前处理:检查子部门与关联用户
|
||||
*
|
||||
* @param int $id 待删除的部门 ID
|
||||
* @return void
|
||||
* @throws \Exception 当存在子部门时抛出
|
||||
* @throws BizException 当存在子部门或关联用户时抛出
|
||||
*/
|
||||
protected function beforeDelete(int $id): void
|
||||
{
|
||||
$childCount = Dept::where('parent_id', $id)->count();
|
||||
if ($childCount > 0) {
|
||||
throw new \Exception('该部门下存在子部门,不可删除');
|
||||
$hasChildren = Dept::where('parent_id', $id)->exists();
|
||||
if ($hasChildren) {
|
||||
throw new BizException('存在子部门,不可删除');
|
||||
}
|
||||
|
||||
$hasUsers = User::where('dept_id', $id)->exists();
|
||||
if ($hasUsers) {
|
||||
throw new BizException('该部门下存在用户,不可删除');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
:::tip 树形接口
|
||||
部门使用 `_list()` 而非 `_index()`,返回全量数据后由前端或 Logic 构建树形结构。
|
||||
:::
|
||||
## 删除保护流程
|
||||
|
||||
### Controller 示例
|
||||
|
||||
```php
|
||||
// app/controller/DeptController.php
|
||||
|
||||
class DeptController extends BaseController
|
||||
{
|
||||
protected DeptLogic $logic;
|
||||
|
||||
protected function initialize(): void
|
||||
{
|
||||
$this->logic = new DeptLogic();
|
||||
}
|
||||
|
||||
#[Log('部门管理-查询树形列表', Log::TYPE_QUERY)]
|
||||
#[Permission('sys:dept:page', '部门列表')]
|
||||
public function list(): Json
|
||||
{
|
||||
return parent::_list($this->logic);
|
||||
}
|
||||
|
||||
#[Log('部门管理-新增记录', Log::TYPE_ADD, '新增部门:{name}')]
|
||||
#[Permission('sys:dept:add', '添加部门')]
|
||||
public function add(): Json
|
||||
{
|
||||
return parent::_add($this->logic, $this->getJsonBody());
|
||||
}
|
||||
|
||||
// ... 其他 CRUD 方法
|
||||
}
|
||||
```text
|
||||
DELETE /api/dept/delete/{id}
|
||||
│
|
||||
▼
|
||||
Controller::_delete() → Service::delete()
|
||||
│
|
||||
▼
|
||||
beforeDelete($id)
|
||||
├─ 存在子部门 → throw BizException('存在子部门,不可删除')
|
||||
├─ 存在关联用户 → throw BizException('该部门下存在用户,不可删除')
|
||||
│ → 事务回滚 → {"code":1,"msg":"...不可删除"}
|
||||
▼
|
||||
DB::transaction { 软删除 is_delete=1(树形子节点不级联,需逐级删除) }
|
||||
```
|
||||
|
||||
:::warning 不做级联删除
|
||||
删除父部门不会级联删除子部门(软删除保持数据可恢复)。正确顺序:自叶子节点向上逐级删除;存在子部门时接口直接返回业务错误。
|
||||
:::
|
||||
|
||||
## 与其他模块的关系
|
||||
|
||||
| 关系 | 说明 |
|
||||
|------|------|
|
||||
| 用户 → 部门 | `laravel_user.dept_id` 外键,数据权限按此字段过滤 |
|
||||
| 部门树 → 前端 | 用户表单中以级联选择器回显(`deptName` 由 `afterDetail` 补全) |
|
||||
| 数据权限 | 范围解析为 2(本部门)时查询叠加 `WHERE dept_id = 本部门`(见 [5.9 数据权限](/zh/core/data-scope.html);当前默认解析为 1) |
|
||||
|
||||
@@ -1,146 +1,84 @@
|
||||
# 6.14 文件模板管理
|
||||
|
||||
:::tip 概述
|
||||
文件模板管理用于维护系统中使用的文件模板,支持按编码查询模板。继承 BaseLogic(系统共享,不隔离)。
|
||||
文件模板管理用于维护系统中使用的文件模板(如导出模板、协议文本等),支持按编码查询模板。`FileTemplateService` 继承 `BaseService`(系统共享,不隔离)。
|
||||
:::
|
||||
|
||||
## 模块特点
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 基类 | BaseLogic(系统共享,不隔离) |
|
||||
| 文件字段 | file_path(模板文件),保存到 file_template 目录 |
|
||||
| 唯一校验 | code 全局唯一 |
|
||||
| 特殊接口 | getTemplateByCode 按编码查询模板 |
|
||||
| 基类 | `BaseService`(系统共享,不隔离) |
|
||||
| Trait 组合 | `HasCrudOperations, HasFileProcessing, HasQueryScopes, HasUniqueCheck` |
|
||||
| 文件字段 | `file_path`(模板文件路径),读取时补全域名 |
|
||||
| 唯一校验 | `code` 全局唯一 |
|
||||
| 模糊搜索 | `name`、`code` |
|
||||
| 特殊接口 | `getTemplateByCode` 按编码查询模板 |
|
||||
|
||||
## API 接口
|
||||
|
||||
| 方法 | 路径 | 权限码 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/file/template/page` | sys:fileTemplate:page | 分页列表 |
|
||||
| GET | `/api/file/template/detail/:id` | sys:fileTemplate:detail | 详情 |
|
||||
| GET | `/api/file/template/detail/{id}` | sys:fileTemplate:detail | 详情 |
|
||||
| POST | `/api/file/template/add` | sys:fileTemplate:add | 新增 |
|
||||
| PUT | `/api/file/template/update` | sys:fileTemplate:update | 修改 |
|
||||
| DELETE | `/api/file/template/delete/:id` | sys:fileTemplate:delete | 删除 |
|
||||
| DELETE | `/api/file/template/delete/{id}` | sys:fileTemplate:delete | 删除 |
|
||||
| DELETE | `/api/file/template/batchDelete` | sys:fileTemplate:batchDelete | 批量删除 |
|
||||
| GET | `/api/file/template/getTemplateByCode/:code` | - | 按编码查询模板 |
|
||||
| GET | `/api/file/template/getTemplateByCode/{code}` | — | 按编码查询模板 |
|
||||
|
||||
## 核心代码
|
||||
:::tip 路径风格
|
||||
本模块路由为二级路径 `file/template/*`(2026-10 起由驼峰 `fileTemplate/*` 调整),前端 API 模块 URL 需与之一致。
|
||||
:::
|
||||
|
||||
## Service 配置(来自项目实际代码)
|
||||
|
||||
```php
|
||||
// app/logic/FileTemplateLogic.php
|
||||
|
||||
class FileTemplateLogic extends BaseLogic
|
||||
class FileTemplateService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 关联的模型类
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
use HasCrudOperations, HasFileProcessing, HasQueryScopes, HasUniqueCheck;
|
||||
|
||||
protected string $modelClass = FileTemplate::class;
|
||||
|
||||
/**
|
||||
* 需要自动处理的文件上传字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
// 单文件字段:模板文件路径保存时迁移、读取时补全域名
|
||||
protected array $fileFields = ['file_path'];
|
||||
|
||||
/**
|
||||
* 文件保存的子目录
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $fileSaveDir = 'file_template';
|
||||
|
||||
/**
|
||||
* LIKE 模糊匹配字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageLikeFields = ['name', 'code'];
|
||||
protected array $pageEqFields = [];
|
||||
|
||||
/**
|
||||
* 默认排序规则
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
|
||||
|
||||
/**
|
||||
* 唯一性校验字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
// code 全局唯一
|
||||
protected array $uniqueFields = ['code'];
|
||||
|
||||
// 按编码查询模板
|
||||
public function getByCode(string $code): ?array
|
||||
{
|
||||
$result = FileTemplate::where('code', $code)->find();
|
||||
return $result ? $result->toArray() : null;
|
||||
}
|
||||
// 按编码查询模板(getTemplateByCode 接口使用)
|
||||
public function getTemplateByCode(string $code): ?array { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Controller 代码
|
||||
## Controller(getTemplateByCode)
|
||||
|
||||
```php
|
||||
// app/controller/FileTemplateController.php
|
||||
// app/Http/Controllers/Api/FileTemplateController.php(来自项目实际代码)
|
||||
|
||||
class FileTemplateController extends BaseController
|
||||
public function getTemplateByCode(string $code): JsonResponse
|
||||
{
|
||||
protected FileTemplateLogic $logic;
|
||||
|
||||
protected function initialize(): void
|
||||
{
|
||||
$this->logic = new FileTemplateLogic();
|
||||
$result = $this->fileTemplateService->getTemplateByCode($code);
|
||||
if (! $result) {
|
||||
return $this->fail('模板不存在');
|
||||
}
|
||||
|
||||
#[Log('文件模板-查询分页记录', Log::TYPE_QUERY)]
|
||||
#[Permission('sys:fileTemplate:page', '模板分页')]
|
||||
public function page(): Json
|
||||
{
|
||||
return parent::_index($this->logic);
|
||||
}
|
||||
|
||||
#[Log('文件模板-新增记录', Log::TYPE_ADD, '新增模板:{name}')]
|
||||
#[Permission('sys:fileTemplate:add', '添加模板')]
|
||||
public function add(): Json
|
||||
{
|
||||
return parent::_add($this->logic, $this->getJsonBody());
|
||||
}
|
||||
|
||||
#[Log('文件模板-修改记录', Log::TYPE_UPDATE, '修改模板ID:{id}')]
|
||||
#[Permission('sys:fileTemplate:update', '修改模板')]
|
||||
public function update(): Json
|
||||
{
|
||||
$data = $this->getJsonBody();
|
||||
$id = $data['id'] ?? 0;
|
||||
if (empty($id)) return $this->fail('缺少id参数');
|
||||
return parent::_edit($this->logic, $id, $data);
|
||||
}
|
||||
|
||||
#[Log('文件模板-删除记录', Log::TYPE_DELETE, '删除模板ID:{id}')]
|
||||
#[Permission('sys:fileTemplate:delete', '删除模板')]
|
||||
public function delete(int $id): Json
|
||||
{
|
||||
return parent::_remove($this->logic, $id);
|
||||
}
|
||||
|
||||
// 按编码查询(无权限注解,已登录即可访问)
|
||||
#[Log('文件模板-按编码查询', Log::TYPE_QUERY)]
|
||||
public function getTemplateByCode(string $code): Json
|
||||
{
|
||||
$result = $this->logic->getByCode($code);
|
||||
if (!$result) {
|
||||
return $this->fail('模板不存在', 404);
|
||||
}
|
||||
return $this->success($result);
|
||||
}
|
||||
return $this->success($result);
|
||||
}
|
||||
```
|
||||
|
||||
:::tip 按编码查询
|
||||
`getTemplateByCode` 接口未配置权限注解,通常用于前端按编码读取模板配置的场景。
|
||||
:::
|
||||
`getTemplateByCode` 接口未配置 `#[Permission]` 注解,所有已登录用户均可访问——常用于前端按编码读取模板配置的场景(如打印模板、协议文本动态加载)。
|
||||
:::
|
||||
|
||||
## 使用场景
|
||||
|
||||
| 场景 | 编码示例 | 说明 |
|
||||
|------|---------|------|
|
||||
| 导出模板 | `export_user` | 前端按编码取模板定义渲染导出字段 |
|
||||
| 协议/合同文本 | `agreement_v1` | 动态加载富文本模板 |
|
||||
| 通知文案 | `notice_welcome` | 统一维护文案,改库即生效 |
|
||||
|
||||
@@ -1,49 +1,61 @@
|
||||
# 6.15 首页与个人中心
|
||||
|
||||
:::tip 概述
|
||||
首页与个人中心模块负责当前登录用户的信息获取、菜单加载、密码修改和个人信息更新。该模块不继承 BaseLogic,而是专用业务逻辑。
|
||||
首页与个人中心模块负责当前登录用户的信息获取、菜单加载、密码修改和个人信息更新。逻辑由专用的 `IndexService` 承载(不走 BaseService CRUD),接口位于认证组内但**无需配置 `#[Permission]`**——已登录即可访问。
|
||||
:::
|
||||
|
||||
## 模块特点
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 基类 | 无(直接操作 Model) |
|
||||
| 用途 | 当前登录用户的自身操作 |
|
||||
| 权限 | 无需配置 #[Permission],已登录即可访问 |
|
||||
| 超级管理员 | uid=1 拥有全部权限 `*:*:*` |
|
||||
| Service | `IndexService`(专用逻辑,非 CRUD 服务) |
|
||||
| 权限 | 无需 `#[Permission]`,已登录即可访问 |
|
||||
| 权限缓存复用 | `getUser` 的权限列表复用 `JwtService::getUserPermissions`(Redis 缓存),与 `Authenticate` 中间件保持一致 |
|
||||
| 敏感字段 | 返回前移除 `password` / `salt` |
|
||||
| 修改密码 | 改密成功后**吊销该用户全部令牌**(全端下线) |
|
||||
|
||||
## API 接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/index/getUser` | 获取当前登录用户信息(含角色和权限) |
|
||||
| GET | `/api/index/getMenus` | 获取当前用户菜单(树形) |
|
||||
| PUT | `/api/index/updatePassword` | 修改密码 |
|
||||
| GET | `/api/index/getMenus` | 获取当前用户菜单(树形,动态路由数据源) |
|
||||
| PUT | `/api/index/updatePassword` | 修改密码(改密后全端下线) |
|
||||
| PUT | `/api/index/updateProfile` | 更新个人信息 |
|
||||
|
||||
## 核心代码
|
||||
## 核心代码(来自项目实际代码)
|
||||
|
||||
### 获取用户信息
|
||||
|
||||
```php
|
||||
// app/logic/IndexLogic.php
|
||||
// app/Services/IndexService.php(节选)
|
||||
|
||||
public function getUser(int $userId): array
|
||||
{
|
||||
$user = User::with(['dept'])->find($userId);
|
||||
if (!$user) {
|
||||
throw new \Exception('用户不存在');
|
||||
if (! $user) {
|
||||
throw new BizException('用户不存在');
|
||||
}
|
||||
|
||||
$data = $user->toArray();
|
||||
|
||||
// 头像补全域名前缀
|
||||
if (!empty($data['avatar'])) {
|
||||
// 头像补全域名
|
||||
if (! empty($data['avatar'])) {
|
||||
$data['avatar'] = get_file_url($data['avatar']);
|
||||
}
|
||||
|
||||
// 组装城市编码数组(province_code, city_code, district_code, street_code)
|
||||
// 获取角色列表(完整角色对象)
|
||||
$roleIds = UserRole::where('user_id', $userId)->pluck('role_id')->toArray();
|
||||
if (! empty($roleIds)) {
|
||||
$data['roles'] = Role::whereIn('id', $roleIds)->get()->toArray();
|
||||
} else {
|
||||
$data['roles'] = [];
|
||||
}
|
||||
|
||||
// 获取权限列表:复用 JwtService 的 Redis 缓存,与 Authenticate 保持一致
|
||||
$data['permissions'] = app(JwtService::class)->getUserPermissions($userId);
|
||||
|
||||
// 组装城市编码数组,供前端级联选择回显
|
||||
$data['city'] = [
|
||||
$data['province_code'] ?? '',
|
||||
$data['city_code'] ?? '',
|
||||
@@ -51,33 +63,6 @@ public function getUser(int $userId): array
|
||||
$data['street_code'] ?? '',
|
||||
];
|
||||
|
||||
// 获取角色列表(完整角色对象)
|
||||
$roleIds = UserRole::where('user_id', $userId)->column('role_id');
|
||||
if (!empty($roleIds)) {
|
||||
$data['roles'] = Role::whereIn('id', $roleIds)->select()->toArray();
|
||||
} else {
|
||||
$data['roles'] = [];
|
||||
}
|
||||
|
||||
// 获取权限列表
|
||||
if ($userId === 1) {
|
||||
$data['permissions'] = ['*:*:*'];
|
||||
} else {
|
||||
if (!empty($roleIds)) {
|
||||
$data['permissions'] = RoleMenu::alias('rm')
|
||||
->join('think_menu m', 'rm.menu_id = m.id')
|
||||
->whereIn('rm.role_id', $roleIds)
|
||||
->where('m.is_delete', 0)
|
||||
->where('m.type', 1)
|
||||
->column('m.permission');
|
||||
} else {
|
||||
$data['permissions'] = [];
|
||||
}
|
||||
}
|
||||
|
||||
// authorities 字段(预留)
|
||||
$data['authorities'] = null;
|
||||
|
||||
// 隐藏敏感字段
|
||||
unset($data['password'], $data['salt']);
|
||||
|
||||
@@ -93,119 +78,141 @@ public function getUser(int $userId): array
|
||||
- 字段名由 `Result::success()` 自动转为 camelCase 返回前端
|
||||
:::
|
||||
|
||||
### 获取菜单
|
||||
### 获取菜单(动态路由数据源)
|
||||
|
||||
```php
|
||||
/**
|
||||
* 获取当前用户菜单(树形)
|
||||
*
|
||||
* - 超管(userId=1):返回全部启用的目录/菜单;
|
||||
* - 普通用户:按角色关联的菜单ID取菜单,再组装为树形;
|
||||
* - 无角色或无关联菜单时返回空数组。
|
||||
*
|
||||
* 字段说明:
|
||||
* - hide:0=可见,1=不可见(取 hide=0);
|
||||
* - type:0=菜单/目录,1=节点/权限按钮(取 0);
|
||||
* - status:0=正常,1=停用(取 0)。
|
||||
*/
|
||||
public function getMenus(int $userId): array
|
||||
{
|
||||
// 超级管理员:全部菜单
|
||||
// type=0 仅返回可导航菜单(目录+页面),type=1 为权限按钮不返回
|
||||
// 超级管理员获取所有菜单
|
||||
if ($userId === 1) {
|
||||
$menus = Menu::where('status', 0)
|
||||
->where('hide', 0)
|
||||
->where('type', 0)
|
||||
->where('is_delete', 0)
|
||||
->order('sort', 'asc')
|
||||
->select()->toArray();
|
||||
->orderBy('sort', 'asc')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return $this->buildTree($menus);
|
||||
}
|
||||
|
||||
// 普通用户:通过角色关联获取菜单
|
||||
$roleIds = UserRole::where('user_id', $userId)->column('role_id');
|
||||
if (empty($roleIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$menuIds = RoleMenu::whereIn('role_id', $roleIds)->column('menu_id');
|
||||
$menuIds = array_unique($menuIds);
|
||||
|
||||
if (empty($menuIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$menus = Menu::whereIn('id', $menuIds)
|
||||
->where('status', 0)
|
||||
->where('hide', 0)
|
||||
->where('type', 0)
|
||||
->where('is_delete', 0)
|
||||
->order('sort', 'asc')
|
||||
->select()->toArray();
|
||||
|
||||
return $this->buildTree($menus);
|
||||
// 获取用户角色 → 角色关联菜单ID(去重)→ 过滤后组装树形
|
||||
// (同样附加 status=0、hide=0、type=0、is_delete=0 四个条件)
|
||||
}
|
||||
```
|
||||
|
||||
:::info 菜单查询条件
|
||||
获取菜单(非节点)数据时保证四个条件:`type=0`(可导航菜单)、`status=0`(启用)、`hide=0`(未隐藏)、`is_delete=0`(未删除)。
|
||||
:::
|
||||
|
||||
:::info 菜单类型说明
|
||||
代码中 `where('type', 0)` 用于筛选可导航的菜单项(目录和页面)。在当前实现中,`type=0` 表示可导航的菜单项(包括目录和页面),`type=1` 表示仅用于权限控制的按钮节点,不会出现在菜单树中。
|
||||
:::info 菜单查询四条件
|
||||
获取可导航菜单时保证四个条件:`type=0`(菜单/目录)、`status=0`(启用)、`hide=0`(未隐藏)、`is_delete=0`(未删除)。`type=1` 的权限节点不返回给前端菜单树(仅供后端鉴权)。详见 [6.3 菜单管理](/zh/modules/menu.html)。
|
||||
:::
|
||||
|
||||
### 修改密码
|
||||
|
||||
```php
|
||||
/**
|
||||
* 流程:校验旧密码 → 加密新密码并保存 → 吊销该用户全部令牌。
|
||||
*
|
||||
* @throws BizException 用户不存在或旧密码错误时抛出
|
||||
*/
|
||||
public function updatePassword(int $userId, string $oldPassword, string $newPassword): bool
|
||||
{
|
||||
$user = User::find($userId);
|
||||
|
||||
// 验证旧密码
|
||||
if (!PasswordService::verify($oldPassword, $user->password, $user->salt)) {
|
||||
throw new \Exception('旧密码错误');
|
||||
// 验证旧密码(bcrypt 双重加盐)
|
||||
if (! PasswordService::verify($oldPassword, $user->password, $user->salt)) {
|
||||
throw new BizException('旧密码错误');
|
||||
}
|
||||
|
||||
// 加密新密码
|
||||
// 加密新密码(外部盐 + bcrypt)
|
||||
$encrypted = PasswordService::encrypt($newPassword);
|
||||
$user->password = $encrypted['password'];
|
||||
$user->salt = $encrypted['salt'];
|
||||
return $user->save() !== false;
|
||||
$saved = $user->save();
|
||||
|
||||
if ($saved) {
|
||||
// 改密后全端下线:吊销该用户全部令牌 + 权限缓存
|
||||
app(JwtService::class)->revokeAllTokens($userId);
|
||||
}
|
||||
|
||||
return $saved !== false;
|
||||
}
|
||||
```
|
||||
|
||||
:::warning 改密安全闭环
|
||||
修改密码成功后立即调用 `revokeAllTokens($userId)`——删除 Redis 中该用户**全部** refresh_token 与权限缓存,所有已登录设备(含攻击者持有的旧会话)下一次请求即失效。见 [5.1 用户认证(JWT)](/zh/core/jwt.html)。
|
||||
:::
|
||||
|
||||
### 更新个人信息
|
||||
|
||||
```php
|
||||
public function updateProfile(int $userId, array $data): bool
|
||||
{
|
||||
$user = User::find($userId);
|
||||
|
||||
// 处理城市字段
|
||||
if (isset($data['city']) && is_array($data['city'])) {
|
||||
$data['province_code'] = $data['city'][0] ?? '';
|
||||
$data['city_code'] = $data['city'][1] ?? '';
|
||||
$data['district_code'] = $data['city'][2] ?? '';
|
||||
$data['street_code'] = $data['city'][3] ?? '';
|
||||
$data['city_info'] = City::resolveCityNames($data['city']);
|
||||
unset($data['city']);
|
||||
if (! $user) {
|
||||
throw new BizException('用户不存在');
|
||||
}
|
||||
|
||||
// 排除敏感字段
|
||||
unset($data['password'], $data['salt'], $data['role_id'], $data['tenant_id'], $data['id']);
|
||||
// 字段白名单:仅允许更新以下字段
|
||||
$allowFields = ['realname', 'gender', 'avatar', 'mobile', 'email', 'intro', 'note', 'address'];
|
||||
$updateData = [];
|
||||
foreach ($allowFields as $field) {
|
||||
if (isset($data[$field])) {
|
||||
$updateData[$field] = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
return $user->save($data) !== false;
|
||||
// 处理城市字段:city 数组拆分到 4 个独立字段,并生成 city_info
|
||||
if (isset($data['city']) && is_array($data['city'])) {
|
||||
$updateData['province_code'] = $data['city'][0] ?? '';
|
||||
$updateData['city_code'] = $data['city'][1] ?? '';
|
||||
$updateData['district_code'] = $data['city'][2] ?? '';
|
||||
$updateData['street_code'] = $data['city'][3] ?? '';
|
||||
$updateData['city_info'] = City::resolveCityNames($data['city']);
|
||||
}
|
||||
|
||||
$user->update($updateData);
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
:::warning 安全设计
|
||||
updateProfile 会排除 `password`、`salt`、`role_id`、`tenant_id` 等敏感字段,防止用户越权修改自身权限。
|
||||
:::warning 越权防护
|
||||
`updateProfile` 采用字段白名单(`realname / gender / avatar / mobile / email / intro / note / address`),`city` 数组拆分写入四级编码;`password`、`salt`、`role_id`、`tenant_id`、`id` 等不在名单内的字段一律忽略,防止用户通过个人信息接口越权修改自身密码、角色或租户归属——改密码必须走 `updatePassword`(校验旧密码 + 全端下线)。
|
||||
:::
|
||||
|
||||
## 前端调用
|
||||
|
||||
```typescript
|
||||
// 获取当前用户信息
|
||||
// 获取当前用户信息(登录后 / 刷新页面时)
|
||||
const userInfo = await getUserInfo();
|
||||
// userInfo.permissions = ['sys:user:page', 'sys:article:add', ...]
|
||||
// userInfo.roles = [1]
|
||||
// userInfo.roles = [{id:1, name:'超级管理员', ...}]
|
||||
// userInfo.city = [省编码, 市编码, 区编码, 街道编码]
|
||||
|
||||
// 获取菜单(用于动态路由生成)
|
||||
const menus = await getMenuList();
|
||||
// menus = [{id:1, name:'系统管理', path:'/system', children:[...]}]
|
||||
// menus = [{id:1, name:'控制面板', path:'/dashboard', children:[...]}]
|
||||
|
||||
// 修改密码
|
||||
// 修改密码(成功后前端需清除令牌重新登录 —— 后端已全端吊销)
|
||||
await updatePassword({ oldPassword: '123456', newPassword: '654321' });
|
||||
|
||||
// 更新个人信息
|
||||
await updateProfile({ realname: '张三', mobile: '13800138000' });
|
||||
```
|
||||
```
|
||||
|
||||
## 相关页面
|
||||
|
||||
- [4.2 请求处理链路](/zh/architecture/request-flow.html) — 认证与菜单加载在链路中的位置
|
||||
- [5.4 权限管理(RBAC)](/zh/core/rbac.html) — 权限码与角色体系
|
||||
- [9.5 路由与动态菜单](/zh/frontend/router.html) — 前端如何用 getMenus 生成路由
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 6.11 定时任务
|
||||
|
||||
:::tip 概述
|
||||
定时任务系统由三部分组成:JobLogic(调度逻辑)+ BaseTask(任务处理器)+ JobRunCommand(命令行入口)。支持三种触发方式:Linux Crontab、Windows 计划任务、Daemon 守护进程。
|
||||
定时任务系统由三部分组成:`JobService`(调度逻辑)+ `BaseTask`(任务处理器)+ Artisan 命令(`job:run` / `job:daemon` 命令行入口)。支持三种触发方式:Linux Crontab、Windows 计划任务、Daemon 守护进程。
|
||||
:::
|
||||
|
||||
## 系统架构
|
||||
@@ -13,29 +13,29 @@
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Linux Crontab│ │Windows 计划 │ │ Daemon 守护 │ │
|
||||
│ │ │ │任务 │ │ 进程 │ │
|
||||
│ │ * * * * * │ │ schtasks │ │ php think │ │
|
||||
│ │ php think │ │ │ │ job:daemon │ │
|
||||
│ │ * * * * * │ │ schtasks │ │ php artisan │ │
|
||||
│ │ php artisan │ │ │ │ job:daemon │ │
|
||||
│ │ job:run │ │ │ │ │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ │ │ │ │
|
||||
│ └─────────────────┼─────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ JobRunCommand │ │
|
||||
│ │ php think job:run │ │
|
||||
│ │ 或 php think job:daemon│ │
|
||||
│ └────────────┬────────────┘ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ JobRunCommand / JobDaemon │ │
|
||||
│ │ php artisan job:run │ │
|
||||
│ │ php artisan job:daemon │ │
|
||||
│ └────────────┬────────────────┘ │
|
||||
└───────────────────────────┼─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 调度层(JobLogic) │
|
||||
│ 调度层(JobService) │
|
||||
│ │
|
||||
│ runPending() shouldRun() │
|
||||
│ ├─ 查询 status=1 的任务 ├─ 解析 cron 表达式 │
|
||||
│ ├─ 逐个判断是否到执行时间 ├─ 分 时 日 月 周 逐段匹配 │
|
||||
│ └─ 到期则执行 └─ 全部匹配返回 true │
|
||||
│ runPending() shouldRun() │
|
||||
│ ├─ 查询 status=1 的任务 ├─ 解析 cron 表达式 │
|
||||
│ ├─ 逐个判断是否到执行时间 ├─ 分 时 日 月 周 逐段匹配 │
|
||||
│ └─ 到期则执行 └─ 全部匹配返回 true │
|
||||
│ │
|
||||
│ executeJob($job) │
|
||||
│ ├─ 检查执行策略(立即/排队/放弃) │
|
||||
@@ -49,21 +49,53 @@
|
||||
│ 执行层 │
|
||||
│ │
|
||||
│ doExecute($job) │
|
||||
│ ├─ 判断 URL 类型 │
|
||||
│ │ │
|
||||
│ ├─ 判断任务类型 │
|
||||
│ │ │
|
||||
│ ├─ http:// / https:// → executeHttp() │
|
||||
│ │ └─ curl 发起 HTTP GET/POST 请求 │
|
||||
│ │ │
|
||||
│ └─ 其它 → executeInternal() │
|
||||
│ └─ 映射到 app\task\{Name}Task::run() │
|
||||
│ │ └─ curl 发起 HTTP 请求 │
|
||||
│ │ │
|
||||
│ └─ 其它 → executeInternal() │
|
||||
│ └─ 映射到 App\Task\{Name}Task::run() │
|
||||
│ │
|
||||
│ 示例: │
|
||||
│ url = "SendSms" → app\task\SendSmsTask::run() │
|
||||
│ url = "Order/Close" → app\task\Order\CloseTask::run() │
|
||||
│ url = "https://..." → HTTP GET/POST │
|
||||
│ 示例: │
|
||||
│ url = "SendSms" → App\Task\SendSmsTask::run() │
|
||||
│ url = "Order/Close" → App\Task\Order\CloseTask::run() │
|
||||
│ url = "https://..." → HTTP 请求 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 任务数据结构
|
||||
|
||||
### job 表字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `job_name` | varchar | 任务名称 |
|
||||
| `job_alias` | varchar | 任务别名(唯一) |
|
||||
| `job_group` | varchar | 任务分组 |
|
||||
| `status` | tinyint | 状态:0=未发布 1=运行中 2=暂停 |
|
||||
| `cron_expression` | varchar | Cron 表达式(5段:分 时 日 月 周) |
|
||||
| `execute_policy` | tinyint | 执行策略:1=立即 2=执行一次 3=放弃 |
|
||||
| `is_sync` | tinyint | 是否同步:0=否 1=是 |
|
||||
| `url` | varchar | 任务地址(内部类名或外部URL) |
|
||||
| `params` | text | 任务参数(JSON) |
|
||||
| `note` | text | 备注 |
|
||||
|
||||
### job_log 表字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `job_id` | int | 关联任务ID |
|
||||
| `job_name` | varchar | 任务名称 |
|
||||
| `job_group` | varchar | 任务分组 |
|
||||
| `job_message` | text | 执行结果消息(截取前500字符) |
|
||||
| `cron_expression` | varchar | Cron 表达式 |
|
||||
| `status` | tinyint | 状态:0=正常 1=失败 |
|
||||
| `start_time` | datetime | 开始时间 |
|
||||
| `end_time` | datetime | 结束时间 |
|
||||
| `consume_time` | int | 耗时(毫秒) |
|
||||
| `exception_info` | text | 异常信息(截取前2000字符) |
|
||||
|
||||
## 状态说明
|
||||
|
||||
| 状态值 | 说明 | 可执行 | 可暂停 | 可启动 |
|
||||
@@ -81,17 +113,6 @@
|
||||
| 2 | 执行一次 | 等待上一次完成后执行 |
|
||||
| 3 | 放弃执行 | 如果上一次还在运行则跳过 |
|
||||
|
||||
:::tip 枚举文本自动翻译
|
||||
JobLogic 配置了 `serializeMaps`,API 响应中会自动包含翻译后的文本字段:
|
||||
|
||||
| 原始字段 | 文本字段 | 字典编码 | 说明 |
|
||||
|----------|----------|----------|------|
|
||||
| `status` | `statusText` | `job_status` | 任务状态(未发布/运行中/暂停/删除) |
|
||||
| `execute_policy` | `executePolicyText` | `job_execute_policy` | 执行策略(立即执行/执行一次/放弃执行) |
|
||||
|
||||
字典数据在代码生成器自动创建,也可在「系统管理 → 字典管理」中手动维护。
|
||||
:::
|
||||
|
||||
## Cron 表达式语法
|
||||
|
||||
标准 5 段格式:`分 时 日 月 周`
|
||||
@@ -119,197 +140,87 @@ JobLogic 配置了 `serializeMaps`,API 响应中会自动包含翻译后的文
|
||||
| `0 9-18 * * 1-5` | 工作日 9-18 点每小时 |
|
||||
| `0 2 1,15 * *` | 每月 1 号和 15 号凌晨 2 点 |
|
||||
|
||||
---
|
||||
|
||||
## 方式一:Linux Crontab(推荐)
|
||||
|
||||
:::tip 适用场景
|
||||
Linux / macOS 服务器,最常用的定时任务触发方式。系统级 cron 每分钟触发一次 PHP 命令。
|
||||
Linux / macOS 服务器,最常用的定时任务触发方式。系统级 cron 每分钟触发一次 Artisan 命令。
|
||||
:::
|
||||
|
||||
### 配置步骤
|
||||
|
||||
#### 1. 编辑 crontab
|
||||
|
||||
```bash
|
||||
# 1. 编辑 crontab
|
||||
crontab -e
|
||||
```
|
||||
|
||||
#### 2. 添加定时任务
|
||||
# 2. 添加定时任务(每分钟执行一次,推荐)
|
||||
* * * * * cd /www/RXThinkCMF_Laravel13_EleVue && /usr/bin/php artisan job:run >> /dev/null 2>&1
|
||||
|
||||
```bash
|
||||
# 每分钟执行一次(推荐)
|
||||
* * * * * cd /www/api && php think job:run >> /dev/null 2>&1
|
||||
# 调试时改为写入日志文件
|
||||
* * * * * cd /www/RXThinkCMF_Laravel13_EleVue && /usr/bin/php artisan job:run >> /var/log/job.log 2>&1
|
||||
|
||||
# 每分钟执行一次,日志写入文件(调试用)
|
||||
* * * * * cd /www/api && php think job:run >> /var/log/job.log 2>&1
|
||||
|
||||
# 每分钟执行一次,带环境变量
|
||||
* * * * * cd /www/api && /usr/bin/php think job:run >> /dev/null 2>&1
|
||||
# 3. 保存后验证
|
||||
crontab -l # 查看已配置的 cron
|
||||
tail -f /var/log/syslog | grep CRON # 查看 cron 日志
|
||||
cd /www/RXThinkCMF_Laravel13_EleVue && php artisan job:run # 手动执行一次
|
||||
```
|
||||
|
||||
:::warning 路径注意
|
||||
- `cd /www/api` 必须切换到项目根目录,否则 ThinkPHP 无法找到配置文件
|
||||
- `cd` 必须切换到**项目根目录**,否则 Laravel 无法找到 `.env` 与配置
|
||||
- `php` 建议使用绝对路径(如 `/usr/bin/php`),避免 cron 环境变量不同导致找不到 PHP
|
||||
- `>> /dev/null 2>&1` 表示丢弃输出,调试时可改为写入日志文件
|
||||
- `>> /dev/null 2>&1` 表示丢弃输出,调试时改为写入日志文件
|
||||
:::
|
||||
|
||||
#### 3. 保存并验证
|
||||
|
||||
```bash
|
||||
# 保存后 cron 自动生效
|
||||
|
||||
# 查看已配置的 cron
|
||||
crontab -l
|
||||
|
||||
# 查看 cron 日志(确认是否正常触发)
|
||||
tail -f /var/log/syslog | grep CRON
|
||||
|
||||
# 手动执行一次验证
|
||||
cd /www/api && php think job:run
|
||||
```
|
||||
|
||||
#### 4. 权限设置
|
||||
|
||||
```bash
|
||||
# 确保 PHP 有权限执行
|
||||
chmod +x /usr/bin/php
|
||||
|
||||
# 确保项目目录权限
|
||||
chmod -R 755 /www/api/runtime/
|
||||
```
|
||||
|
||||
### Crontab 常用命令
|
||||
|
||||
```bash
|
||||
crontab -e # 编辑当前用户的 cron
|
||||
crontab -l # 列出当前用户的 cron
|
||||
crontab -r # 删除当前用户的所有 cron(慎用)
|
||||
crontab -u username -l # 查看指定用户的 cron
|
||||
```
|
||||
|
||||
### 系统 cron 目录
|
||||
|
||||
```text
|
||||
/etc/crontab # 系统级 cron 配置
|
||||
/etc/cron.d/ # 额外的 cron 配置文件目录
|
||||
/etc/cron.daily/ # 每天执行的脚本
|
||||
/etc/cron.hourly/ # 每小时执行的脚本
|
||||
/etc/cron.weekly/ # 每周执行的脚本
|
||||
/etc/cron.monthly/ # 每月执行的脚本
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方式二:Windows 计划任务
|
||||
|
||||
:::tip 适用场景
|
||||
Windows Server 或本地开发环境,通过任务计划程序定时触发。
|
||||
Windows Server 或本地开发环境(Laragon),通过任务计划程序定时触发。
|
||||
:::
|
||||
|
||||
### 方式 A:图形界面配置
|
||||
|
||||
#### 1. 打开任务计划程序
|
||||
|
||||
```text
|
||||
方法1:Win + R → 输入 taskschd.msc → 回车
|
||||
方法2:控制面板 → 管理工具 → 任务计划程序
|
||||
方法3:开始菜单搜索"任务计划程序"
|
||||
```
|
||||
|
||||
#### 2. 创建基本任务
|
||||
|
||||
1. 右侧点击"创建基本任务"
|
||||
2. 名称:`RXThinkCMF 定时任务`
|
||||
3. 描述:`每分钟执行一次定时任务扫描`
|
||||
4. 触发器:选择"每天"
|
||||
5. 开始时间:设置当前时间
|
||||
6. 重复任务间隔:`1 分钟`
|
||||
7. 持续时间:`无限期`
|
||||
8. 操作:选择"启动程序"
|
||||
9. 程序或脚本:
|
||||
|
||||
```text
|
||||
C:\xampp\php\php.exe
|
||||
```
|
||||
|
||||
10. 添加参数:
|
||||
|
||||
```text
|
||||
think job:run
|
||||
```
|
||||
|
||||
11. 起始于:
|
||||
|
||||
```text
|
||||
D:\xampp\htdocs\v3\thinkphp6
|
||||
```
|
||||
|
||||
12. 勾选"不管用户是否登录都要运行"
|
||||
13. 点击完成
|
||||
|
||||
#### 3. 验证
|
||||
|
||||
```powershell
|
||||
# 手动运行测试
|
||||
cd D:\xampp\htdocs\v3\thinkphp6
|
||||
C:\xampp\php\php.exe think job:run
|
||||
|
||||
# 在任务计划程序中右键任务 → 运行
|
||||
# 查看"上次运行结果"是否为 0x0
|
||||
1. Win + R → taskschd.msc → 回车(打开任务计划程序)
|
||||
2. 右侧点击"创建基本任务"
|
||||
3. 名称:定时任务
|
||||
4. 触发器:每天,重复间隔 1 分钟,持续时间无限期
|
||||
5. 操作:启动程序
|
||||
程序或脚本:D:\laragon\bin\php\php-8.3.x-Win32-vs16-x64\php.exe
|
||||
添加参数:artisan job:run
|
||||
起始于:D:\laragon\www\RXThinkCMF_Laravel13_EleVue
|
||||
6. 勾选"不管用户是否登录都要运行" → 完成
|
||||
```
|
||||
|
||||
### 方式 B:命令行配置(schtasks)
|
||||
|
||||
```powershell
|
||||
# 创建每分钟执行的任务
|
||||
schtasks /create /tn "RXThinkCMF_Job" /tr "C:\xampp\php\php.exe think job:run" /sc minute /mo 1 /st 00:00 /f
|
||||
# 创建每分钟执行的任务(含工作目录与日志重定向)
|
||||
schtasks /create /tn "RXThinkCMF_Job" /tr "cmd /c cd /d D:\laragon\www\RXThinkCMF_Laravel13_EleVue && D:\laragon\bin\php\php-8.3.x-Win32-vs16-x64\php.exe artisan job:run >> storage\job.log 2>&1" /sc minute /mo 1 /f
|
||||
|
||||
# 参数说明:
|
||||
# /tn 任务名称
|
||||
# /tr 要执行的命令
|
||||
# /sc 频率:minute(分钟)、hourly(小时)、daily(天)
|
||||
# /mo 间隔:1 表示每 1 分钟
|
||||
# /st 开始时间
|
||||
# /f 强制覆盖同名任务
|
||||
|
||||
# 创建任务(指定工作目录)
|
||||
schtasks /create /tn "RXThinkCMF_Job" /tr "cmd /c cd /d D:\xampp\htdocs\v3\thinkphp6 && C:\xampp\php\php.exe think job:run" /sc minute /mo 1 /f
|
||||
|
||||
# 查询任务
|
||||
# 查询 / 手动运行 / 删除
|
||||
schtasks /query /tn "RXThinkCMF_Job"
|
||||
|
||||
# 手动运行
|
||||
schtasks /run /tn "RXThinkCMF_Job"
|
||||
|
||||
# 删除任务
|
||||
schtasks /delete /tn "RXThinkCMF_Job" /f
|
||||
|
||||
# 查看所有任务
|
||||
schtasks /query /fo table
|
||||
```
|
||||
|
||||
### Windows 注意事项
|
||||
|
||||
:::warning 常见问题
|
||||
1. **路径空格**:如果路径含空格,需要用引号包裹
|
||||
2. **PHP 路径**:使用 `php.exe` 的完整路径
|
||||
:::warning Windows 常见问题
|
||||
1. **路径空格**:路径含空格时必须用引号包裹
|
||||
2. **PHP 路径**:使用 `php.exe` 的完整路径(Laragon 多版本共存时尤其注意 8.3 的路径)
|
||||
3. **工作目录**:必须设置为项目根目录
|
||||
4. **权限**:确保任务以有权限的用户身份运行
|
||||
5. **日志**:可在参数中添加 `>> log.txt 2>&1` 重定向输出
|
||||
4. **输出重定向**:`>>` 在 `cmd /c` 内生效,注意相对路径基于工作目录
|
||||
:::
|
||||
|
||||
```powershell
|
||||
# 带日志输出的完整命令
|
||||
schtasks /create /tn "RXThinkCMF_Job" /tr "cmd /c cd /d D:\xampp\htdocs\v3\thinkphp6 && C:\xampp\php\php.exe think job:run >> runtime\job.log 2>&1" /sc minute /mo 1 /f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方式三:Daemon 守护进程
|
||||
|
||||
:::tip 适用场景
|
||||
需要更精确的执行时机(秒级),或不想依赖系统 cron 的场景。进程常驻内存,自行调度。
|
||||
需要更精确的执行时机,或不想依赖系统 cron 的场景。进程常驻内存,自行调度。
|
||||
:::
|
||||
|
||||
### 原理
|
||||
@@ -327,71 +238,70 @@ Daemon 进程启动
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### 实现方式
|
||||
- 锁文件:`storage/app/job_daemon.lock`(内容为当前进程 PID,防止重复启动)
|
||||
- 防重复:同一任务在同一分钟内只执行一次(`hasRunThisMinute` 查 JobLog)
|
||||
|
||||
#### 方式 A:使用 Supervisor 管理(推荐生产环境)
|
||||
### 运行方式
|
||||
|
||||
创建命令文件:
|
||||
**常用命令:**
|
||||
|
||||
```php
|
||||
// app/command/JobDaemonCommand.php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
use app\logic\JobLogic;
|
||||
|
||||
/**
|
||||
* 定时任务守护进程命令
|
||||
*
|
||||
* 常驻内存运行,每分钟扫描并执行到期任务。
|
||||
* 建议通过 Supervisor 管理,确保进程异常退出后自动重启。
|
||||
*
|
||||
* 用法:php think job:daemon
|
||||
*/
|
||||
class JobDaemonCommand extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName('job:daemon')
|
||||
->setDescription('定时任务守护进程(常驻内存)');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output): void
|
||||
{
|
||||
$output->writeln('[' . date('Y-m-d H:i:s') . '] Daemon 启动...');
|
||||
|
||||
$logic = new JobLogic();
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
$count = $logic->runPending();
|
||||
if ($count > 0) {
|
||||
$output->writeln('[' . date('Y-m-d H:i:s') . '] 执行了 ' . $count . ' 个任务');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$output->writeln('[' . date('Y-m-d H:i:s') . '] 异常: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// 休眠 60 秒
|
||||
sleep(60);
|
||||
}
|
||||
}
|
||||
}
|
||||
```bash
|
||||
php artisan job:daemon # 前台常驻(开发观察)
|
||||
php artisan job:daemon --stop # 停止
|
||||
php artisan job:run # 一次性执行(交给系统 cron 每分钟触发)
|
||||
nohup php artisan job:daemon > storage/logs/job_daemon.log 2>&1 & # 后台
|
||||
```
|
||||
|
||||
Supervisor 配置:
|
||||
#### 方式 A:前台运行(本地调试)
|
||||
|
||||
```cmd
|
||||
:: 项目根目录打开 CMD —— 启动
|
||||
php artisan job:daemon
|
||||
|
||||
:: 停止(另开一个 CMD 窗口)
|
||||
php artisan job:daemon --stop
|
||||
```
|
||||
|
||||
**启动成功输出:**
|
||||
|
||||
```text
|
||||
[2026-10-07 22:10:06] Daemon 已启动 (PID: 32116)
|
||||
[2026-10-07 22:10:06] 每分钟检查一次到期任务,按 Ctrl+C 优雅退出
|
||||
```
|
||||
|
||||
**运行日志:**
|
||||
|
||||
```text
|
||||
[2026-10-07 22:10:06] [执行] 定时任务演示 (cron: */5 * * * *)
|
||||
[2026-10-07 22:10:06] [执行] 定时任务案例 (cron: */5 * * * *)
|
||||
[2026-10-07 22:10:06] 本轮执行完成,共执行 2 个任务
|
||||
```
|
||||
|
||||
| 标记 | 含义 |
|
||||
|------|------|
|
||||
| `[执行]` | 任务已触发执行,括号内显示 cron 表达式 |
|
||||
| `[跳过]` | 同一分钟内该任务已执行过,自动跳过(防重复) |
|
||||
| `[失败]` | 任务执行异常,后跟错误信息(单个任务失败不影响其他任务) |
|
||||
| `本轮执行完成` | 当前分钟扫描完毕,显示执行数量 |
|
||||
|
||||
:::tip
|
||||
没有到期任务时 Daemon 静默等待(不输出日志),属正常行为。
|
||||
:::
|
||||
|
||||
**前端联动:** Daemon 启动后,「定时任务」模块顶部显示绿色「守护进程运行中 (PID: xxx)」按钮;未启动时显示灰色。
|
||||
|
||||
:::tip 状态检测原理
|
||||
前端通过 `GET /api/job/daemonStatus` 轮询,后端读取 `storage/app/job_daemon.lock` 中的 PID 并检查进程是否存活。若进程非正常退出但锁文件残留,前端可能仍显示「运行中」——执行 `php artisan job:daemon --stop` 或删除锁文件清理。
|
||||
:::
|
||||
|
||||
#### 方式 B:Supervisor 管理(Linux 生产推荐)
|
||||
|
||||
```ini
|
||||
; /etc/supervisor/conf.d/rxthinkcmf-job.conf
|
||||
|
||||
[program:rxthinkcmf-job]
|
||||
command=php /www/api/think job:daemon
|
||||
directory=/www/api
|
||||
command=php /www/RXThinkCMF_Laravel13_EleVue/artisan job:daemon
|
||||
directory=/www/RXThinkCMF_Laravel13_EleVue
|
||||
autostart=true
|
||||
autorestart=true
|
||||
user=www-data
|
||||
@@ -402,108 +312,14 @@ stdout_logfile_maxbytes=10MB
|
||||
stdout_logfile_backups=5
|
||||
```
|
||||
|
||||
启动:
|
||||
|
||||
```bash
|
||||
# 重新加载配置
|
||||
sudo supervisorctl reread
|
||||
sudo supervisorctl update
|
||||
|
||||
# 启动
|
||||
sudo supervisorctl reread && sudo supervisorctl update
|
||||
sudo supervisorctl start rxthinkcmf-job
|
||||
|
||||
# 查看状态
|
||||
sudo supervisorctl status rxthinkcmf-job
|
||||
|
||||
# 重启
|
||||
sudo supervisorctl restart rxthinkcmf-job
|
||||
|
||||
# 查看日志
|
||||
tail -f /var/log/rxthinkcmf-job.log
|
||||
```
|
||||
|
||||
#### 方式 B:Windows CMD 运行
|
||||
|
||||
在项目根目录打开 CMD,执行以下命令:
|
||||
|
||||
```cmd
|
||||
:: 启动 Daemon(前台运行,关闭窗口则停止)
|
||||
php think job:daemon
|
||||
|
||||
:: 停止 Daemon(另开一个 CMD 窗口执行)
|
||||
php think job:daemon --stop
|
||||
```
|
||||
|
||||
**启动成功输出:**
|
||||
|
||||
```text
|
||||
[2026-09-23 09:06:07] Daemon 已启动 (PID: 22720)
|
||||
[2026-09-23 09:06:07] 每分钟检查一次到期任务,按 Ctrl+C 优雅退出
|
||||
```
|
||||
|
||||
**运行过程中的日志输出:**
|
||||
|
||||
```text
|
||||
[2026-09-23 11:25:00] [执行] 定时任务演示 (cron: */5 * * * *)
|
||||
[2026-09-23 11:25:00] [执行] 定时任务案例 (cron: */5 * * * *)
|
||||
[2026-09-23 11:25:00] 本轮执行完成,共执行 2 个任务
|
||||
[2026-09-23 11:30:00] [执行] 定时任务演示 (cron: */5 * * * *)
|
||||
[2026-09-23 11:30:00] [跳过] 定时任务案例 - 当前分钟已执行过
|
||||
[2026-09-23 11:30:00] 本轮执行完成,共执行 1 个任务
|
||||
```
|
||||
|
||||
日志类型说明:
|
||||
|
||||
| 标记 | 含义 |
|
||||
|------|------|
|
||||
| `[执行]` | 任务已触发执行,括号内显示 cron 表达式 |
|
||||
| `[跳过]` | 同一分钟内该任务已执行过,自动跳过(防重复) |
|
||||
| `[失败]` | 任务执行异常,后跟错误信息 |
|
||||
| `本轮执行完成` | 当前分钟的所有任务扫描完毕,显示执行数量 |
|
||||
|
||||
:::tip 提示
|
||||
- 没有到期任务时,Daemon 不输出任何日行(静默等待),属于正常行为
|
||||
- `[跳过]` 是防重复机制,同一任务在同一分钟内只会执行一次
|
||||
- 出现 `[失败]` 时检查对应的任务处理器代码,单个任务失败不影响其他任务
|
||||
:::
|
||||
|
||||
**前端联动:** Daemon 启动后,后台管理系统的「定时任务」模块会自动检测到守护进程状态,页面顶部显示:
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ [新增] [删除] [🟢 守护进程运行中 (PID: 22720)] │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 绿色「守护进程运行中」按钮 — 表示 Daemon 正常工作,鼠标悬停可查看 PID
|
||||
- 灰色「守护进程未运行」— 表示 Daemon 未启动或已停止
|
||||
|
||||
:::tip 状态检测原理
|
||||
前端通过 `GET /api/job/daemonStatus` 接口轮询状态,后端读取 `runtime/job_daemon.lock` 锁文件中的 PID 并检查进程是否存活。因此即使 Daemon 非正常退出(如强制杀进程),只要锁文件残留,前端仍可能显示「运行中」,此时需手动删除 `runtime/job_daemon.lock` 或执行 `php think job:daemon --stop` 清理。
|
||||
:::
|
||||
|
||||
:::warning Windows 注意事项
|
||||
1. **无信号扩展**:Windows 不支持 `pcntl_signal`,`Ctrl+C` 会强制终止进程而非优雅退出
|
||||
2. **停止方式**:推荐使用 `php think job:daemon --stop`,或关闭 CMD 窗口
|
||||
3. **防重复启动**:命令内部通过文件锁(`runtime/job_daemon.lock`)防止重复启动,启动前会自动检测并提示 `Daemon 已在运行中 (PID: xxx)`
|
||||
4. **后台运行**:如需后台运行,可使用 `start /B php think job:daemon`,停止时用 `php think job:daemon --stop`
|
||||
5. **窗口关闭即停止**:前台运行时关闭 CMD 窗口会终止进程,生产环境建议配合 Windows 服务或计划任务守护
|
||||
:::
|
||||
|
||||
#### 方式 C:nohup 后台运行(简单场景)
|
||||
|
||||
```bash
|
||||
# 后台启动
|
||||
nohup php /www/api/think job:daemon >> /var/log/job.log 2>&1 &
|
||||
|
||||
# 查看进程
|
||||
ps aux | grep job:daemon
|
||||
|
||||
# 停止
|
||||
kill $(pgrep -f "job:daemon")
|
||||
```
|
||||
|
||||
#### 方式 D:systemd 服务(推荐)
|
||||
#### 方式 C:systemd 服务(Linux 生产推荐)
|
||||
|
||||
```ini
|
||||
; /etc/systemd/system/rxthinkcmf-job.service
|
||||
@@ -515,8 +331,8 @@ After=network.target mysql.service
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/www/api
|
||||
ExecStart=/usr/bin/php /www/api/think job:daemon
|
||||
WorkingDirectory=/www/RXThinkCMF_Laravel13_EleVue
|
||||
ExecStart=/usr/bin/php /www/RXThinkCMF_Laravel13_EleVue/artisan job:daemon
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
@@ -525,54 +341,54 @@ WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
# 启用并启动
|
||||
sudo systemctl enable rxthinkcmf-job
|
||||
sudo systemctl start rxthinkcmf-job
|
||||
|
||||
# 查看状态
|
||||
sudo systemctl status rxthinkcmf-job
|
||||
|
||||
# 查看日志
|
||||
sudo journalctl -u rxthinkcmf-job -f
|
||||
|
||||
# 重启
|
||||
sudo systemctl restart rxthinkcmf-job
|
||||
```
|
||||
|
||||
---
|
||||
#### 方式 D:nohup 后台运行(简单场景)
|
||||
|
||||
## 三种方式对比
|
||||
```bash
|
||||
nohup php artisan job:daemon > storage/logs/job_daemon.log 2>&1 &
|
||||
ps aux | grep job:daemon
|
||||
kill $(pgrep -f "job:daemon")
|
||||
```
|
||||
|
||||
#### Docker 环境
|
||||
|
||||
```bash
|
||||
docker compose exec app php artisan job:daemon
|
||||
# 或以独立常驻容器运行 app 镜像的 artisan 命令
|
||||
```
|
||||
|
||||
### 三种方式对比
|
||||
|
||||
| 特性 | Crontab | Windows 计划任务 | Daemon 守护进程 |
|
||||
|------|---------|-----------------|----------------|
|
||||
| 适用系统 | Linux / macOS | Windows | 全平台 |
|
||||
| 最小间隔 | 1 分钟 | 1 分钟 | 1 秒 |
|
||||
| 最小间隔 | 1 分钟 | 1 分钟 | 分钟级(每轮 sleep 60s) |
|
||||
| 资源占用 | 低(按需启动) | 低(按需启动) | 常驻内存 |
|
||||
| 可靠性 | 高 | 高 | 高(配合 Supervisor) |
|
||||
| 配置复杂度 | 低 | 中 | 低 |
|
||||
| 可靠性 | 高 | 高 | 高(配合 Supervisor/systemd) |
|
||||
| 日志追踪 | 需重定向 | 任务计划程序 | 自带输出 |
|
||||
| 推荐场景 | 生产环境 | Windows 生产 | 需要秒级精度 / 开发调试 |
|
||||
| 推荐场景 | 生产环境 | Windows 生产 | 开发调试 / 不便配置 cron |
|
||||
|
||||
:::tip 推荐选择
|
||||
- **Linux 生产环境**:Crontab(简单可靠)
|
||||
- **Windows 生产环境**:任务计划程序 或 Daemon 守护进程
|
||||
- **需要秒级精度**:Daemon + Supervisor
|
||||
- **Windows 生产环境**:任务计划程序 或 Daemon + 计划任务守护
|
||||
- **Docker 环境**:Daemon(容器内运行)
|
||||
- **本地开发调试**:Daemon 前台运行(直接观察日志输出)
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 新建定时任务步骤
|
||||
|
||||
### 1. 创建任务处理器
|
||||
|
||||
```php
|
||||
<?php
|
||||
// app/task/CleanLogTask.php
|
||||
declare(strict_types=1);
|
||||
// app/Task/CleanLogTask.php(本步骤新建的处理器)
|
||||
|
||||
namespace app\task;
|
||||
namespace App\Task;
|
||||
|
||||
/**
|
||||
* 清理过期日志任务
|
||||
@@ -595,23 +411,25 @@ class CleanLogTask extends BaseTask
|
||||
$cutoffDate = date('Y-m-d H:i:s', strtotime("-{$days} days"));
|
||||
|
||||
// 清理登录日志
|
||||
$loginCount = \app\model\LoginLog::where('create_time', '<', $cutoffDate)->delete();
|
||||
$loginCount = LoginLog::where('create_time', '<', $cutoffDate)->delete();
|
||||
|
||||
// 清理操作日志
|
||||
$operCount = \app\model\OperationLog::where('create_time', '<', $cutoffDate)->delete();
|
||||
$operCount = OperationLog::where('create_time', '<', $cutoffDate)->delete();
|
||||
|
||||
// 清理任务日志
|
||||
$jobCount = \app\model\JobLog::where('start_time', '<', $cutoffDate)->delete();
|
||||
$jobCount = JobLog::where('start_time', '<', $cutoffDate)->delete();
|
||||
|
||||
return "已清理 {$days} 天前的日志:登录日志 {$loginCount} 条,操作日志 {$operCount} 条,任务日志 {$jobCount} 条";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
内置示例处理器:`app/Task/HelloWorldTask.php`(验证 daemon 调度)、`app/Task/SendSmsTask.php`(示例任务)。
|
||||
|
||||
### 2. 在数据库 job 表中配置
|
||||
|
||||
```sql
|
||||
INSERT INTO think_job (
|
||||
INSERT INTO laravel_job (
|
||||
job_name, job_alias, job_group, job_trigger,
|
||||
url, params, cron_expression,
|
||||
execute_policy, status, is_sync,
|
||||
@@ -624,57 +442,80 @@ INSERT INTO think_job (
|
||||
);
|
||||
```
|
||||
|
||||
(也可在「定时任务」管理界面新增。)
|
||||
|
||||
### 3. 配置触发方式(三选一)
|
||||
|
||||
```bash
|
||||
# 方式一:Linux Crontab
|
||||
crontab -e
|
||||
# 添加:* * * * * cd /www/api && php think job:run >> /dev/null 2>&1
|
||||
# 添加:* * * * * cd /项目路径 && /usr/bin/php artisan job:run >> /dev/null 2>&1
|
||||
|
||||
# 方式二:Windows 计划任务
|
||||
schtasks /create /tn "RXThinkCMF_Job" /tr "cmd /c cd /d D:\xampp\htdocs\v3\thinkphp6 && C:\xampp\php\php.exe think job:run" /sc minute /mo 1 /f
|
||||
# 方式二:Windows 计划任务(见上文 schtasks 示例)
|
||||
|
||||
# 方式三:Daemon 守护进程
|
||||
php think job:daemon
|
||||
# 停止:php think job:daemon --stop
|
||||
php artisan job:daemon
|
||||
# 停止:php artisan job:daemon --stop
|
||||
```
|
||||
|
||||
---
|
||||
## Service 配置(摘自 `app/Services/JobService.php` 实际代码)
|
||||
|
||||
```php
|
||||
class JobService extends BaseService
|
||||
{
|
||||
use HasCrudOperations, HasQueryScopes, HasUniqueCheck;
|
||||
|
||||
protected string $modelClass = Job::class;
|
||||
|
||||
// 任务名 / 任务别名模糊匹配
|
||||
protected array $pageLikeFields = ['job_name', 'job_alias'];
|
||||
|
||||
// 状态 / 任务分组精确匹配
|
||||
protected array $pageEqFields = ['status', 'job_group'];
|
||||
|
||||
// 任务别名全局唯一
|
||||
protected array $uniqueFields = ['job_alias'];
|
||||
}
|
||||
```
|
||||
|
||||
:::info 未配置字典翻译与文件字段
|
||||
任务表的 `status`、`execute_policy` 等枚举直接以数值存储,前端按 [6.11 定时任务](/zh/modules/job.html) 状态说明渲染,`JobService` 未声明 `serializeMaps` / `fileFields`。
|
||||
:::
|
||||
|
||||
## API 接口
|
||||
|
||||
### 任务管理
|
||||
|
||||
| 方法 | 路径 | 权限码 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/job/page` | sys:job:page | 分页列表 |
|
||||
| GET | `/api/job/list` | sys:job:list | 全量列表 |
|
||||
| GET | `/api/job/detail/:id` | sys:job:detail | 详情 |
|
||||
| GET | `/api/job/runOnce/:id` | sys:job:execute | 手动执行一次 |
|
||||
| GET | `/api/job/pause/:id` | sys:job:pause | 暂停任务 |
|
||||
| GET | `/api/job/resume/:id` | sys:job:resume | 恢复任务 |
|
||||
| GET | `/api/job/daemonStatus` | - | 守护进程状态 |
|
||||
| GET | `/api/job/detail/{id}` | sys:job:detail | 详情 |
|
||||
| GET | `/api/job/runOnce/{id}` | sys:job:execute | 手动执行一次 |
|
||||
| GET | `/api/job/pause/{id}` | sys:job:pause | 暂停任务 |
|
||||
| GET | `/api/job/resume/{id}` | sys:job:resume | 恢复任务 |
|
||||
| GET | `/api/job/daemonStatus` | — | 守护进程状态 |
|
||||
| POST | `/api/job/add` | sys:job:add | 新增任务 |
|
||||
| POST | `/api/job/status` | sys:job:update | 设置状态 |
|
||||
| PUT | `/api/job/update` | sys:job:update | 修改任务 |
|
||||
| DELETE | `/api/job/delete/:id` | sys:job:delete | 删除任务 |
|
||||
| DELETE | `/api/job/delete/{id}` | sys:job:delete | 删除任务 |
|
||||
| DELETE | `/api/job/batchDelete` | sys:job:batchDelete | 批量删除 |
|
||||
|
||||
### 任务日志
|
||||
|
||||
| 方法 | 路径 | 权限码 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/job/log/page` | sys:job:log:page | 日志分页 |
|
||||
| GET | `/api/job/log/detail/:id` | sys:job:log:detail | 日志详情 |
|
||||
| DELETE | `/api/job/log/delete/:id` | sys:job:log:delete | 删除日志 |
|
||||
| DELETE | `/api/job/log/batchDelete` | sys:job:log:batchDelete | 批量删除 |
|
||||
|
||||
---
|
||||
| GET | `/api/job/log/page` | sys:job:page | 日志分页 |
|
||||
| GET | `/api/job/log/detail/{id}` | sys:job:detail | 日志详情 |
|
||||
| DELETE | `/api/job/log/delete/{id}` | sys:job:delete | 删除日志 |
|
||||
| DELETE | `/api/job/log/batchDelete` | sys:job:batchDelete | 批量删除 |
|
||||
|
||||
## 核心代码解析
|
||||
|
||||
### Cron 表达式匹配(JobLogic::matchCronField)
|
||||
### Cron 表达式匹配(JobService::matchCronField)
|
||||
|
||||
```php
|
||||
// app/logic/JobLogic.php
|
||||
// app/Services/JobService.php(来自项目实际代码)
|
||||
|
||||
/**
|
||||
* 匹配 cron 字段
|
||||
@@ -694,12 +535,12 @@ protected function matchCronField(string $cronField, int $current): bool
|
||||
|
||||
// 处理 */n(每隔 n 分钟/小时等)
|
||||
if (preg_match('/^\*\/(\d+)$/', $cronField, $m)) {
|
||||
return $current % (int)$m[1] === 0;
|
||||
return $current % (int) $m[1] === 0;
|
||||
}
|
||||
|
||||
// 处理 n-m(范围)
|
||||
if (preg_match('/^(\d+)-(\d+)$/', $cronField, $m)) {
|
||||
return $current >= (int)$m[1] && $current <= (int)$m[2];
|
||||
return $current >= (int) $m[1] && $current <= (int) $m[2];
|
||||
}
|
||||
|
||||
// 处理逗号分隔的多个值
|
||||
@@ -709,11 +550,11 @@ protected function matchCronField(string $cronField, int $current): bool
|
||||
}
|
||||
|
||||
// 单个值
|
||||
return (int)$cronField === $current;
|
||||
return (int) $cronField === $current;
|
||||
}
|
||||
```
|
||||
|
||||
### 防重复执行(JobLogic::hasRunThisMinute)
|
||||
### 防重复执行(JobService::hasRunThisMinute)
|
||||
|
||||
```php
|
||||
/**
|
||||
@@ -733,56 +574,74 @@ public function hasRunThisMinute(int $jobId): bool
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
### JobService 能力清单
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `runPending()` | 扫描并执行全部到期任务,返回执行数 |
|
||||
| `shouldRun($job)` | 解析 cron 判断任务是否到期 |
|
||||
| `executeJob($job)` | 执行入口(策略检查 + 执行 + 记录 JobLog) |
|
||||
| `runOnce($id)` | 手动执行一次(忽略 cron) |
|
||||
| `setStatus($id, $status)` / `pause($id)` / `resume($id)` | 状态流转 |
|
||||
| `executeHttp($url, $params)` | HTTP 类任务执行 |
|
||||
| `executeInternal($url, $params)` | 内部 Task 类执行(映射 `App\Task\{Name}Task`) |
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **互斥运行**:Cron 模式和 Daemon 模式不要同时使用,否则会导致任务重复执行
|
||||
2. **防重复执行**:Daemon 模式内置防重复机制,同一任务在同一分钟内不会重复执行
|
||||
3. **错误隔离**:单个任务执行失败不影响其他任务和主循环
|
||||
4. **优雅退出**:Daemon 支持 `SIGTERM`/`SIGINT` 信号,`Ctrl+C` 或 `kill` 均可优雅停止
|
||||
5. **锁文件**:Daemon 的锁文件位于 `storage/app/job_daemon.lock`,异常退出后残留锁会自动检测并清理
|
||||
6. **Windows 兼容**:Daemon 在 Windows 下可正常运行,但信号处理(pcntl)不可用,需通过 `--stop` 参数或 `taskkill` 停止
|
||||
7. **日志截断**:执行结果消息截取前 500 字符,异常信息截取前 2000 字符写入 job_log
|
||||
8. **create_user**:命令行执行的任务日志中 `create_user` 由 HasAuditColumns trait 自动处理(无登录用户时为空)
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 任务不执行?
|
||||
|
||||
A: 检查以下几点:
|
||||
|
||||
1. 任务状态是否为 `1`(运行中)
|
||||
2. Cron 表达式格式是否正确(5 段)
|
||||
3. Crontab 是否已配置且 cron 服务已启动
|
||||
4. PHP 路径是否正确(建议使用绝对路径)
|
||||
5. 手动执行 `php think job:run` 是否正常
|
||||
5. 手动执行 `php artisan job:run` 是否正常
|
||||
|
||||
### Q: 任务重复执行?
|
||||
|
||||
A: Daemon 模式下检查 `hasRunThisMinute` 是否生效。Crontab 模式下确保只有一个 cron 在触发。
|
||||
A: Daemon 模式下检查 `hasRunThisMinute` 是否生效;Crontab 模式下确保只有一个 cron 在触发(`crontab -l` 检查重复项)。
|
||||
|
||||
### Q: 任务执行超时?
|
||||
|
||||
A: 检查 PHP 的 `max_execution_time` 配置,长时间任务建议使用 Daemon 模式或设置 `set_time_limit(0)`。
|
||||
A: 检查 PHP 的 `max_execution_time` 配置,长时间任务建议拆分或在处理器内调大 `set_time_limit(0)`。
|
||||
|
||||
### Q: 如何查看任务执行日志?
|
||||
|
||||
A: 两种方式:
|
||||
1. **控制台实时日志**:Daemon 前台运行时,`[执行]`、`[跳过]`、`[失败]` 等日志直接输出到终端
|
||||
2. **数据库持久化日志**:通过 API `/api/job/log/page` 查询,或直接查 `think_job_log` 表
|
||||
|
||||
1. **控制台实时日志**:Daemon 前台运行时,`[执行]`、`[跳过]`、`[失败]` 直接输出到终端
|
||||
2. **数据库持久化日志**:通过 `GET /api/job/log/page` 查询,或直接查 `laravel_job_log` 表
|
||||
|
||||
### Q: Daemon 控制台没有新日志输出,是卡住了吗?
|
||||
|
||||
A: 不是。Daemon 每分钟检查一次,没有到期任务时静默等待,不输出任何日志。这是正常行为,说明当前没有任务需要执行。
|
||||
A: 不是。Daemon 每分钟检查一次,没有到期任务时静默等待,不输出任何日志。
|
||||
|
||||
### Q: Windows 下 Daemon 如何启动和停止?
|
||||
|
||||
A: 在项目根目录打开 CMD:
|
||||
A: 项目根目录打开 CMD:
|
||||
|
||||
```cmd
|
||||
:: 启动
|
||||
php think job:daemon
|
||||
php artisan job:daemon
|
||||
|
||||
:: 停止(另开一个 CMD 窗口)
|
||||
php think job:daemon --stop
|
||||
php artisan job:daemon --stop
|
||||
```
|
||||
|
||||
启动成功输出:
|
||||
如提示 `Daemon 已在运行中 (PID: xxx)`,先执行 `php artisan job:daemon --stop` 停止旧进程,或手动删除 `storage/app/job_daemon.lock`。
|
||||
|
||||
```text
|
||||
[2026-09-23 09:06:07] Daemon 已启动 (PID: 22720)
|
||||
[2026-09-23 09:06:07] 每分钟检查一次到期任务,按 Ctrl+C 优雅退出
|
||||
```
|
||||
### Q: 提示找不到 Task 类?
|
||||
|
||||
启动后,后台管理系统「定时任务」模块顶部会显示绿色「守护进程运行中」按钮(带 PID 提示),表示 Daemon 正常工作。
|
||||
|
||||
如提示 `Daemon 已在运行中 (PID: xxx)`,先执行 `php think job:daemon --stop` 停止旧进程,或手动删除 `runtime/job_daemon.lock` 文件。
|
||||
A: 确认 `url` 字段的类名与 `app/Task/` 下的类文件一致(命名空间 `App\Task`),且类名采用大驼峰(`SendSms` → `SendSmsTask`)。
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
# 6.5 职级管理
|
||||
|
||||
:::tip 概述
|
||||
职级管理维护用户的职级属性数据,支持 Excel 导入导出。继承 BaseTenantLogic 实现租户隔离。
|
||||
职级管理维护用户的职级属性数据,支持 Excel 导入导出。`LevelService` 继承 `BaseTenantService` 实现租户隔离,是"属性配置 + 导入导出扩展方法"的标准组合示例。
|
||||
:::
|
||||
|
||||
## 模块特点
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 基类 | BaseTenantLogic(租户隔离) |
|
||||
| 唯一校验 | name 全局唯一 |
|
||||
| 枚举映射 | status → level_status 字典(1=在用 2=停用) |
|
||||
| 导入导出 | 支持 Excel 导入导出 |
|
||||
| 基类 | `BaseTenantService`(租户隔离) |
|
||||
| 唯一校验 | `name` 按租户分组唯一(`['name','tenant_id']`) |
|
||||
| 枚举映射 | `status` → level_status 字典(1=在用 2=停用) |
|
||||
| 删除保护 | `beforeDelete` 检查业务约束 |
|
||||
| 导入导出 | 支持 Excel 导入导出(`export` / `import` 扩展方法) |
|
||||
|
||||
## API 接口
|
||||
|
||||
@@ -19,82 +20,52 @@
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/level/page` | sys:level:page | 分页列表 |
|
||||
| GET | `/api/level/list` | sys:level:list | 全量列表 |
|
||||
| GET | `/api/level/detail/:id` | sys:level:detail | 详情 |
|
||||
| GET | `/api/level/detail/{id}` | sys:level:detail | 详情 |
|
||||
| POST | `/api/level/add` | sys:level:add | 新增 |
|
||||
| PUT | `/api/level/update` | sys:level:update | 修改 |
|
||||
| DELETE | `/api/level/delete/:id` | sys:level:delete | 删除 |
|
||||
| DELETE | `/api/level/delete/{id}` | sys:level:delete | 删除 |
|
||||
| DELETE | `/api/level/batchDelete` | sys:level:batchDelete | 批量删除 |
|
||||
| GET | `/api/level/export` | sys:level:export | 导出 |
|
||||
| POST | `/api/level/import` | sys:level:import | 导入 |
|
||||
|
||||
## 核心代码
|
||||
|
||||
### Logic 配置
|
||||
## Service 配置(来自项目实际代码)
|
||||
|
||||
```php
|
||||
// app/logic/LevelLogic.php
|
||||
|
||||
class LevelLogic extends BaseTenantLogic
|
||||
class LevelService extends BaseTenantService
|
||||
{
|
||||
/**
|
||||
* 关联的模型类
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
use HasCrudOperations, HasQueryScopes, HasSerializeMaps, HasUniqueCheck;
|
||||
|
||||
protected string $modelClass = Level::class;
|
||||
|
||||
/**
|
||||
* LIKE 模糊匹配字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageLikeFields = ['name'];
|
||||
|
||||
/**
|
||||
* 精确匹配字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageEqFields = ['status'];
|
||||
|
||||
/**
|
||||
* 默认排序规则
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageOrderBy = [
|
||||
['field' => 'tenant_id', 'type' => 'asc'],
|
||||
['field' => 'sort', 'type' => 'asc'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 唯一性校验字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $uniqueFields = ['name'];
|
||||
// name 在同一租户内唯一
|
||||
protected array $uniqueFields = [['name', 'tenant_id']];
|
||||
|
||||
/**
|
||||
* 枚举显示名映射
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
// 字典翻译
|
||||
protected array $serializeMaps = [
|
||||
'status' => 'level_status',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 导出方法
|
||||
## 导出方法
|
||||
|
||||
```php
|
||||
// app/Services/LevelService.php(来自项目实际代码)
|
||||
|
||||
public function export(array $params = []): string
|
||||
{
|
||||
$list = $this->allList($params);
|
||||
|
||||
$headers = [
|
||||
'name' => '职级名称',
|
||||
'statusText' => '状态', // 枚举显示名
|
||||
'statusText' => '状态', // 枚举显示名(serializeMaps 已补全)
|
||||
'sort' => '排序',
|
||||
'create_user' => '创建人',
|
||||
'create_time' => '创建时间',
|
||||
@@ -104,9 +75,11 @@ public function export(array $params = []): string
|
||||
}
|
||||
```
|
||||
|
||||
### 导入方法
|
||||
## 导入方法
|
||||
|
||||
```php
|
||||
// app/Services/LevelService.php(来自项目实际代码)
|
||||
|
||||
public function import(string $filePath): array
|
||||
{
|
||||
$headerMap = [
|
||||
@@ -119,8 +92,8 @@ public function import(string $filePath): array
|
||||
|
||||
// 枚举字段反向映射:文字转数字
|
||||
foreach ($data as &$row) {
|
||||
if (!empty($row['status']) && !is_numeric($row['status'])) {
|
||||
$row['status'] = DictService::getValue('level_status', $row['status']);
|
||||
if (! empty($row['status']) && ! is_numeric($row['status'])) {
|
||||
$row['status'] = DictCacheService::getValue('level_status', $row['status']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,11 +101,11 @@ public function import(string $filePath): array
|
||||
$errors = [];
|
||||
foreach ($data as $index => $row) {
|
||||
try {
|
||||
$this->add($row);
|
||||
$this->add($row); // 走完整 add 流程(钩子/唯一校验/事务)
|
||||
$count++;
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = [
|
||||
'row' => $index + 2,
|
||||
'row' => $index + 2, // +2:表头占第 1 行
|
||||
'name' => $row['name'] ?? '',
|
||||
'reason' => $e->getMessage(),
|
||||
];
|
||||
@@ -143,6 +116,21 @@ public function import(string $filePath): array
|
||||
}
|
||||
```
|
||||
|
||||
:::tip 导入导出模式
|
||||
LevelLogic 是导入导出的标准实现示例。导出使用 `allList()` 获取全量数据,导入使用 `ExcelService::importWithMap()` 解析文件,枚举字段通过 `DictService::getValue()` 反向映射。
|
||||
:::
|
||||
:::tip 导入导出标准模板
|
||||
LevelService 是导入导出的标准实现示例:导出用 `allList()` 取全量数据 + `ExcelService::export()`;导入用 `ExcelService::importWithMap()` 表头映射 + 字典反查 + 逐条 `add()`(单条失败不中断,明细汇总返回)。与 `ExampleService` 同构。
|
||||
:::
|
||||
|
||||
## 导入导出流程
|
||||
|
||||
```text
|
||||
导出:
|
||||
GET /api/level/export → _export() → Service::export()
|
||||
→ allList()(含字典翻译) → ExcelService::export() → 返回文件 URL
|
||||
|
||||
导入:
|
||||
POST /api/level/import(FormData)
|
||||
→ _import()(校验扩展名 xls/xlsx + MIME + 10MB 上限)
|
||||
→ Service::import()
|
||||
→ importWithMap 解析 → 字典反查 → 逐条 add()
|
||||
→ 返回 { count, errors[] }
|
||||
```
|
||||
|
||||
@@ -1,26 +1,53 @@
|
||||
# 6.3 菜单管理
|
||||
|
||||
:::tip 概述
|
||||
菜单管理负责维护系统的菜单树和权限节点,是 RBAC 权限体系的基础数据。
|
||||
菜单管理负责维护系统的菜单树和权限节点,是 RBAC 权限体系的基础数据——**菜单即权限**:菜单表的 `permission` 字段即接口权限编码。`MenuService` 继承 `BaseService`(系统共享,不隔离)。
|
||||
:::
|
||||
|
||||
## 模块特点
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 基类 | BaseLogic(系统共享,不隔离) |
|
||||
| 树形结构 | treeParentField='parent_id',支持树形列表 |
|
||||
| 菜单类型 | type: 0=目录/页面(可导航) 1=权限按钮(仅权限校验,不显示在菜单树) |
|
||||
| 基类 | `BaseService`(系统共享,不做租户隔离) |
|
||||
| 树形结构 | `treeParentField='parent_id'`、`treeLikeField='name'`,支持树形列表与子树搜索 |
|
||||
| 菜单类型 | type: **0=菜单/目录(可导航) 1=权限节点**(`permission` 非空,参与接口鉴权) |
|
||||
| 删除保护 | `beforeDelete` 检查子菜单与角色引用,任一存在则阻止删除 |
|
||||
| 缓存联动 | 菜单变更时 `MenuObserver` 自动清除菜单/权限缓存 |
|
||||
|
||||
## API 接口
|
||||
|
||||
| 方法 | 路径 | 权限码 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/menu/list` | sys:menu:page | 树形列表 |
|
||||
| GET | `/api/menu/detail/:id` | sys:menu:detail | 详情 |
|
||||
| GET | `/api/menu/list` | sys:menu:list | 树形列表 |
|
||||
| GET | `/api/menu/detail/{id}` | sys:menu:detail | 详情 |
|
||||
| POST | `/api/menu/add` | sys:menu:add | 新增 |
|
||||
| PUT | `/api/menu/update` | sys:menu:update | 修改 |
|
||||
| DELETE | `/api/menu/delete/:id` | sys:menu:delete | 删除 |
|
||||
| DELETE | `/api/menu/delete/{id}` | sys:menu:delete | 删除 |
|
||||
|
||||
:::tip 为何没有分页接口?
|
||||
菜单是全局树形数据,体量小且前端需要完整树渲染,因此只提供 `list`(全量树形)而非 `page`(分页)。
|
||||
:::
|
||||
|
||||
## Service 属性配置
|
||||
|
||||
```php
|
||||
// app/Services/MenuService.php(来自项目实际代码)
|
||||
|
||||
class MenuService extends BaseService
|
||||
{
|
||||
use HasCrudOperations, HasQueryScopes;
|
||||
|
||||
protected string $modelClass = Menu::class;
|
||||
|
||||
protected array $pageLikeFields = ['name'];
|
||||
protected array $pageEqFields = ['status', 'type'];
|
||||
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
|
||||
|
||||
// 树形结构
|
||||
protected string $treeParentField = 'parent_id';
|
||||
protected string $treeLikeField = 'name'; // 树形搜索字段(命中节点保留父链)
|
||||
}
|
||||
```
|
||||
|
||||
## 菜单数据结构
|
||||
|
||||
@@ -42,16 +69,22 @@
|
||||
"id": 101,
|
||||
"parentId": 100,
|
||||
"name": "用户管理",
|
||||
"type": 1,
|
||||
"type": 0,
|
||||
"path": "/user",
|
||||
"component": "system/user/index",
|
||||
"permission": "sys:user:page",
|
||||
"component": "/system/user/index",
|
||||
"children": [
|
||||
{
|
||||
"id": 102,
|
||||
"parentId": 101,
|
||||
"name": "查看用户",
|
||||
"type": 1,
|
||||
"permission": "sys:user:page"
|
||||
},
|
||||
{
|
||||
"id": 103,
|
||||
"parentId": 101,
|
||||
"name": "添加用户",
|
||||
"type": 2,
|
||||
"type": 1,
|
||||
"permission": "sys:user:add"
|
||||
}
|
||||
]
|
||||
@@ -60,76 +93,66 @@
|
||||
}
|
||||
```
|
||||
|
||||
(键名经驼峰自动转换;`children` 由树形组装生成。`GET /index/getMenus` 只返回 `type=0` 的可导航节点——`type=1` 权限节点不进菜单树,仅供鉴权查询。)
|
||||
|
||||
## 字段说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `type` | 0=目录 1=菜单 2=按钮 |
|
||||
| `path` | 路由路径 |
|
||||
| `component` | 前端组件路径(type=1 时必填) |
|
||||
| `permission` | 权限标识(type=1,2 时填写) |
|
||||
| `target` | 打开方式:0=内部 1=iframe 2=外链 |
|
||||
| `hide` | 是否隐藏 |
|
||||
| `type` | **0=菜单/目录(可导航) 1=权限节点**(鉴权专用,`permission` 必填) |
|
||||
| `status` | 0=正常 1=停用(`getMenus` 只取 0) |
|
||||
| `hide` | 0=可见 1=不可见(`getMenus` 只取 0) |
|
||||
| `path` | 路由路径(type=0) |
|
||||
| `component` | 前端组件路径(以 `/` 开头,type=0 页面必填,映射到 `views/**/index.vue`) |
|
||||
| `permission` | 权限标识(type=1 必填且非空,格式 `sys:{模块}:{操作}`) |
|
||||
| `target` | 打开方式:0=组件 1=内链 2=外链 |
|
||||
|
||||
## 核心代码
|
||||
## 数据流转
|
||||
|
||||
### Controller
|
||||
```text
|
||||
菜单管理(本模块)
|
||||
│ 写入 laravel_menu
|
||||
▼
|
||||
GET /index/getMenus(IndexController)
|
||||
│ 按当前用户权限过滤(user_role → role_menu → menu)
|
||||
▼
|
||||
前端 asyncRouteStore
|
||||
│ 转换为 Vue Router 动态路由 + 侧边栏菜单
|
||||
▼
|
||||
v-perm 指令按 permission 控制按钮显隐
|
||||
```
|
||||
|
||||
## 删除保护(beforeDelete 钩子)
|
||||
|
||||
```php
|
||||
// app/controller/MenuController.php
|
||||
// app/Services/MenuService.php(摘自实际代码)
|
||||
|
||||
class MenuController extends BaseController
|
||||
protected function beforeDelete(int $id): void
|
||||
{
|
||||
protected MenuLogic $logic;
|
||||
|
||||
protected function initialize(): void
|
||||
{
|
||||
$this->logic = new MenuLogic();
|
||||
// 存在未删除的子菜单时不允许删除(软删除由全局 Scope 过滤)
|
||||
$hasChildren = Menu::where('parent_id', $id)->exists();
|
||||
if ($hasChildren) {
|
||||
throw new BizException('存在子菜单,不可删除');
|
||||
}
|
||||
|
||||
// 树形列表(支持 status 参数过滤)
|
||||
#[Log('菜单管理-查询树形列表', Log::TYPE_QUERY)]
|
||||
#[Permission('sys:menu:page', '菜单列表')]
|
||||
public function list(): Json
|
||||
{
|
||||
$where = [];
|
||||
$status = $this->getParam('status');
|
||||
if ($status !== null && $status !== '') {
|
||||
$where['status'] = $status;
|
||||
}
|
||||
return parent::_list($this->logic, $where);
|
||||
}
|
||||
|
||||
#[Log('菜单管理-新增记录', Log::TYPE_ADD, '新增菜单:{name}')]
|
||||
#[Permission('sys:menu:add', '添加菜单')]
|
||||
public function add(): Json
|
||||
{
|
||||
return parent::_add($this->logic, $this->getJsonBody());
|
||||
}
|
||||
|
||||
#[Log('菜单管理-修改记录', Log::TYPE_UPDATE, '修改菜单ID:{id}')]
|
||||
#[Permission('sys:menu:update', '修改菜单')]
|
||||
public function update(): Json
|
||||
{
|
||||
$data = $this->getJsonBody();
|
||||
$id = $data['id'] ?? 0;
|
||||
if (empty($id)) return $this->fail('缺少id参数');
|
||||
return parent::_edit($this->logic, $id, $data);
|
||||
}
|
||||
|
||||
#[Log('菜单管理-删除记录', Log::TYPE_DELETE, '删除菜单ID:{id}')]
|
||||
#[Permission('sys:menu:delete', '删除菜单')]
|
||||
public function delete(int $id): Json
|
||||
{
|
||||
return parent::_remove($this->logic, $id);
|
||||
// 已被角色引用时不允许删除,避免 role_menu 产生孤儿数据
|
||||
$hasRoles = RoleMenu::where('menu_id', $id)->exists();
|
||||
if ($hasRoles) {
|
||||
throw new BizException('该菜单已被角色引用,不可删除');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
:::tip 菜单类型
|
||||
菜单使用 `_list()` 而非 `_index()`,返回全量树形数据。`type` 字段区分:0=目录、1=菜单、2=按钮。前端只渲染 type=0 和 type=1 的节点。
|
||||
:::warning 删除前校验
|
||||
删除菜单时先检查子菜单(`存在子菜单,不可删除`),再检查角色引用(`该菜单已被角色引用,不可删除`),任一命中即抛出 `BizException` 阻止删除——需先解除引用/删除子菜单。菜单变更后相关角色的权限缓存由 `MenuObserver` 自动清除。
|
||||
:::
|
||||
|
||||
:::warning 删除前校验
|
||||
删除菜单时,`MenuLogic::beforeDelete` 会检查是否存在子菜单(`think_menu` 中 `parent_id` 等于当前 ID 且未删除的记录),如有子菜单则抛出异常阻止删除。需先删除子菜单再删除父菜单。
|
||||
:::
|
||||
## 最佳实践
|
||||
|
||||
| 实践 | 说明 |
|
||||
|------|------|
|
||||
| type=0 管导航 | 目录/页面用 type=0;页面必填 `component`(前端组件路径),目录留空 |
|
||||
| type=1 管权限 | 权限节点用 type=1,`permission` 必填且非空(鉴权查询条件之一) |
|
||||
| 权限节点挂页面下 | type=1 节点作为 type=0 页面的子级,便于角色树勾选 |
|
||||
| 状态与隐藏 | `status=0` 启用、`hide=0` 可见才会出现在 `getMenus` 结果中 |
|
||||
| 权限码规范 | `sys:{模块}:{操作}`,与控制器 `#[Permission]` 完全一致 |
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# 6.12 参数管理
|
||||
|
||||
:::tip 概述
|
||||
参数管理维护系统级配置参数,如默认密码、系统名称等。继承 BaseLogic(系统共享,不隔离),通过 ParamService 读取。
|
||||
参数管理维护系统级配置参数(存于 `laravel_param` 表),如默认密码、限流阈值等。`ParamService`(横切服务)提供带两级缓存的读取方法,业务代码不直接查表。
|
||||
:::
|
||||
|
||||
## 模块特点
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 基类 | BaseLogic(系统共享,不隔离) |
|
||||
| 唯一校验 | code 全局唯一 |
|
||||
| 缓存 | ParamService 两级缓存(请求级 + 持久缓存,1小时过期) |
|
||||
| 模糊搜索 | name、code |
|
||||
| 精确匹配 | type、status |
|
||||
| 基类 | `BaseService`(系统共享,不隔离) |
|
||||
| 唯一校验 | `code` 全局唯一 |
|
||||
| 缓存 | 读取走 `ParamService` 两级缓存(请求级 + 持久缓存) |
|
||||
| 模糊搜索 | `name`、`code` |
|
||||
| 精确匹配 | `type`、`status` |
|
||||
|
||||
## 数据库表结构
|
||||
|
||||
```sql
|
||||
CREATE TABLE `think_param` (
|
||||
CREATE TABLE `laravel_param` (
|
||||
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`name` varchar(100) NOT NULL COMMENT '参数名称',
|
||||
`code` varchar(100) NOT NULL COMMENT '参数编码',
|
||||
@@ -41,60 +41,44 @@ CREATE TABLE `think_param` (
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/param/page` | sys:param:page | 分页列表 |
|
||||
| GET | `/api/param/list` | sys:param:list | 全量列表 |
|
||||
| GET | `/api/param/detail/:id` | sys:param:detail | 详情 |
|
||||
| GET | `/api/param/detail/{id}` | sys:param:detail | 详情 |
|
||||
| POST | `/api/param/add` | sys:param:add | 新增 |
|
||||
| PUT | `/api/param/update` | sys:param:update | 修改 |
|
||||
| DELETE | `/api/param/delete/:id` | sys:param:delete | 删除 |
|
||||
| DELETE | `/api/param/delete/{id}` | sys:param:delete | 删除 |
|
||||
| DELETE | `/api/param/batchDelete` | sys:param:batchDelete | 批量删除 |
|
||||
|
||||
## 核心代码
|
||||
## Service 配置(摘自 `ParamController` 构造函数中的匿名类,来自项目实际代码)
|
||||
|
||||
本模块**不注入独立 Service 类**,而是在控制器构造函数中以匿名类组合 `BaseService` 与三个 Trait(声明式配置替代独立 Service):
|
||||
|
||||
```php
|
||||
// app/logic/ParamLogic.php
|
||||
|
||||
class ParamLogic extends BaseLogic
|
||||
// app/Http/Controllers/Api/ParamController.php(构造函数内匿名类,节选)
|
||||
$this->paramService = new class extends BaseService
|
||||
{
|
||||
/**
|
||||
* 关联的模型类
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
use HasCrudOperations, HasQueryScopes, HasUniqueCheck;
|
||||
|
||||
protected string $modelClass = Param::class;
|
||||
|
||||
/**
|
||||
* LIKE 模糊匹配字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
// 分页时模糊匹配的字段(name / code 模糊匹配)
|
||||
protected array $pageLikeFields = ['name', 'code'];
|
||||
|
||||
/**
|
||||
* 精确匹配字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageEqFields = ['type', 'status'];
|
||||
// 分页时精确匹配的字段(status 精确匹配)
|
||||
protected array $pageEqFields = ['status'];
|
||||
|
||||
/**
|
||||
* 默认排序规则
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
|
||||
|
||||
/**
|
||||
* 唯一性校验字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
// 唯一性校验:code 全局唯一
|
||||
protected array $uniqueFields = ['code'];
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
:::tip 管理与读取分离
|
||||
- **管理端**(本模块):走标准 CRUD 接口维护参数;
|
||||
- **业务读取**:统一走横切服务 `ParamService::get()` 等静态方法(带缓存),不要在业务代码里直接查 `laravel_param` 表。
|
||||
:::
|
||||
|
||||
## 使用 ParamService 读取参数
|
||||
|
||||
```php
|
||||
use app\service\ParamService;
|
||||
use App\Services\ParamService;
|
||||
|
||||
// 获取字符串参数
|
||||
$value = ParamService::getString('DEFAULT_PASSWORD', '123456');
|
||||
@@ -105,11 +89,17 @@ $value = ParamService::getInt('MAX_LOGIN_ATTEMPTS', 5);
|
||||
// 获取布尔参数
|
||||
$value = ParamService::getBool('ENABLE_SMS', false);
|
||||
|
||||
// 获取默认密码(从系统参数读取,默认 '123456')
|
||||
$password = ParamService::getDefaultPassword();
|
||||
// 获取默认密码(从系统参数读取,默认 '123456',PasswordService 即由此读取)
|
||||
$password = ParamService::get('DEFAULT_PASSWORD', '123456');
|
||||
```
|
||||
|
||||
详细方法与缓存说明见 [5.13 系统参数管理](/zh/core/param.html)。
|
||||
|
||||
:::warning 修改参数后的缓存
|
||||
参数无 Observer 自动失效,修改后需 `ParamService::clearCache($code)` 或 `php artisan cache:clear` 兜底,否则旧值在 TTL 内仍被读取。
|
||||
:::
|
||||
|
||||
:::tip 与配置管理的区别
|
||||
- **参数管理(Param)**:独立的键值对,适合系统级参数(默认密码、限流阈值)
|
||||
- **配置管理(Config)**:分组 + 配置项,适合网站配置(名称、Logo、SMTP)
|
||||
:::
|
||||
- **配置管理(Config)**:分组 + 配置项,适合网站配置(名称、Logo、SMTP),见 [5.12 系统配置管理](/zh/core/config.html)
|
||||
:::
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
# 6.6 岗位管理
|
||||
|
||||
:::tip 概述
|
||||
岗位管理维护用户的岗位属性数据。继承 BaseTenantLogic 实现租户隔离,是典型的零自定义方法模块——全部能力由属性配置自动装配。
|
||||
岗位管理维护用户的岗位属性数据。`PositionService` 继承 `BaseTenantService` 实现租户隔离,全部能力由属性配置自动装配,仅保留删除前的业务校验钩子——是"极简 CRUD"的典型模块。
|
||||
:::
|
||||
|
||||
## 模块特点
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 基类 | BaseTenantLogic(租户隔离) |
|
||||
| 唯一校验 | name 全局唯一 |
|
||||
| 枚举映射 | status → position_status 字典(1=在用 2=停用) |
|
||||
| 自定义方法 | 无(全部由基类自动装配) |
|
||||
| 基类 | `BaseTenantService`(租户隔离) |
|
||||
| 唯一校验 | `name` 按租户分组唯一(`['name','tenant_id']`) |
|
||||
| 枚举映射 | `status` → position_status 字典(1=在用 2=停用) |
|
||||
| 删除保护 | `beforeDelete` 业务校验(如岗位被用户引用时阻止) |
|
||||
| 自定义方法 | 无(分页/详情/增删改全部由 Trait 自动装配) |
|
||||
|
||||
## 数据库表结构
|
||||
|
||||
```sql
|
||||
CREATE TABLE `think_position` (
|
||||
CREATE TABLE `laravel_position` (
|
||||
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`name` varchar(255) NOT NULL COMMENT '岗位名称',
|
||||
`status` int DEFAULT 0 COMMENT '岗位状态:1-在用 2-停用',
|
||||
@@ -36,135 +37,76 @@ CREATE TABLE `think_position` (
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/position/page` | sys:position:page | 分页列表 |
|
||||
| GET | `/api/position/list` | sys:position:list | 全量列表 |
|
||||
| GET | `/api/position/detail/:id` | sys:position:detail | 详情 |
|
||||
| GET | `/api/position/detail/{id}` | sys:position:detail | 详情 |
|
||||
| POST | `/api/position/add` | sys:position:add | 新增 |
|
||||
| PUT | `/api/position/update` | sys:position:update | 修改 |
|
||||
| DELETE | `/api/position/delete/:id` | sys:position:delete | 删除 |
|
||||
| DELETE | `/api/position/delete/{id}` | sys:position:delete | 删除 |
|
||||
| DELETE | `/api/position/batchDelete` | sys:position:batchDelete | 批量删除 |
|
||||
|
||||
## 核心代码
|
||||
## Service 配置(来自项目实际代码)
|
||||
|
||||
```php
|
||||
// app/logic/PositionLogic.php
|
||||
|
||||
class PositionLogic extends BaseTenantLogic
|
||||
class PositionService extends BaseTenantService
|
||||
{
|
||||
/**
|
||||
* 关联的模型类
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
use HasCrudOperations, HasQueryScopes, HasSerializeMaps, HasUniqueCheck;
|
||||
|
||||
protected string $modelClass = Position::class;
|
||||
|
||||
/**
|
||||
* LIKE 模糊匹配字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageLikeFields = ['name'];
|
||||
|
||||
/**
|
||||
* 精确匹配字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageEqFields = ['status'];
|
||||
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
|
||||
|
||||
/**
|
||||
* 默认排序规则
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageOrderBy = [
|
||||
['field' => 'tenant_id', 'type' => 'asc'],
|
||||
['field' => 'sort', 'type' => 'asc'],
|
||||
];
|
||||
// name 在同一租户内唯一
|
||||
protected array $uniqueFields = [['name', 'tenant_id']];
|
||||
|
||||
/**
|
||||
* 唯一性校验字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $uniqueFields = ['name'];
|
||||
|
||||
/**
|
||||
* 枚举显示名映射
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
// 字典翻译
|
||||
protected array $serializeMaps = [
|
||||
'status' => 'position_status',
|
||||
];
|
||||
|
||||
// 删除前业务校验(如岗位被用户引用时抛 BizException 阻止删除)
|
||||
protected function beforeDelete(int $id): void
|
||||
{
|
||||
// ... 业务约束检查 ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Controller 代码
|
||||
## Controller 代码(标准形态)
|
||||
|
||||
```php
|
||||
// app/controller/PositionController.php
|
||||
// app/Http/Controllers/Api/PositionController.php(形态示例)
|
||||
|
||||
class PositionController extends BaseController
|
||||
#[OperationLog('岗位管理-查询分页', OperationLog::TYPE_QUERY)]
|
||||
#[Permission('sys:position:page', '岗位分页')]
|
||||
public function page(): JsonResponse
|
||||
{
|
||||
protected PositionLogic $logic;
|
||||
return $this->_page($this->positionService);
|
||||
}
|
||||
|
||||
protected function initialize(): void
|
||||
{
|
||||
$this->logic = new PositionLogic();
|
||||
}
|
||||
#[OperationLog('岗位管理-新增', OperationLog::TYPE_ADD, '新增岗位:{name}')]
|
||||
#[Permission('sys:position:add', '添加岗位')]
|
||||
public function add(PositionRequest $request): JsonResponse
|
||||
{
|
||||
return $this->_add($this->positionService, $request->validated());
|
||||
}
|
||||
|
||||
#[Log('岗位管理-查询分页记录', Log::TYPE_QUERY)]
|
||||
#[Permission('sys:position:page', '岗位分页')]
|
||||
public function page(): Json
|
||||
{
|
||||
return parent::_index($this->logic);
|
||||
}
|
||||
|
||||
#[Log('岗位管理-查询全量列表', Log::TYPE_QUERY)]
|
||||
public function list(): Json
|
||||
{
|
||||
return parent::_list($this->logic);
|
||||
}
|
||||
|
||||
#[Log('岗位管理-查询详情', Log::TYPE_QUERY, '查询岗位ID:{id}')]
|
||||
public function detail(int $id): Json
|
||||
{
|
||||
return parent::_detail($this->logic, $id);
|
||||
}
|
||||
|
||||
#[Log('岗位管理-新增记录', Log::TYPE_ADD, '新增岗位:{name}')]
|
||||
#[Permission('sys:position:add', '添加岗位')]
|
||||
public function add(): Json
|
||||
{
|
||||
return parent::_add($this->logic, $this->getJsonBody());
|
||||
}
|
||||
|
||||
#[Log('岗位管理-修改记录', Log::TYPE_UPDATE, '修改岗位ID:{id}')]
|
||||
#[Permission('sys:position:update', '修改岗位')]
|
||||
public function update(): Json
|
||||
{
|
||||
$data = $this->getJsonBody();
|
||||
$id = $data['id'] ?? 0;
|
||||
if (empty($id)) return $this->fail('缺少id参数');
|
||||
return parent::_edit($this->logic, $id, $data);
|
||||
}
|
||||
|
||||
#[Log('岗位管理-删除记录', Log::TYPE_DELETE, '删除岗位ID:{id}')]
|
||||
#[Permission('sys:position:delete', '删除岗位')]
|
||||
public function delete(int $id): Json
|
||||
{
|
||||
return parent::_remove($this->logic, $id);
|
||||
}
|
||||
|
||||
#[Log('岗位管理-批量删除', Log::TYPE_DELETE)]
|
||||
#[Permission('sys:position:delete', '删除岗位')]
|
||||
public function batchDelete(): Json
|
||||
{
|
||||
$ids = $this->getJsonBody()['ids'] ?? [];
|
||||
return parent::_batchRemove($this->logic, $ids);
|
||||
}
|
||||
#[OperationLog('岗位管理-删除', OperationLog::TYPE_DELETE, '删除岗位ID:{id}')]
|
||||
#[Permission('sys:position:delete', '删除岗位')]
|
||||
public function delete(int $id): JsonResponse
|
||||
{
|
||||
return $this->_delete($this->positionService, $id);
|
||||
}
|
||||
```
|
||||
|
||||
:::tip 极简 CRUD
|
||||
岗位模块是"零自定义方法"的典型示例——Logic 类只声明属性配置,Controller 调用父类通用方法,无需编写任何业务代码,即可获得完整的增删改查、分页、唯一性校验、枚举翻译能力。
|
||||
:::
|
||||
岗位模块几乎"零手写业务代码"——Service 只声明属性配置与删除校验钩子,Controller 调用基类通用方法,即可获得完整的增删改查、分页、唯一性校验、字典翻译能力。新模块建议从复制本模块结构开始。
|
||||
:::
|
||||
|
||||
## 与其他模块的关系
|
||||
|
||||
| 关系 | 说明 |
|
||||
|------|------|
|
||||
| 用户 → 岗位 | `laravel_user.position_id` 外键,用户表单级联选择 |
|
||||
| 详情回显 | 用户 `afterDetail` 中补全 `positionName` |
|
||||
| 导出映射 | 用户导出列「岗位」由岗位名称映射生成 |
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
# 6.2 角色管理
|
||||
|
||||
:::tip 概述
|
||||
角色管理是 RBAC 权限体系的核心,通过角色关联菜单实现权限分配。
|
||||
角色管理是 RBAC 权限体系的核心,通过角色关联菜单实现权限分配。`RoleService` 继承 `BaseTenantService`(角色按租户隔离),并提供菜单授权、权限缓存清理等专用能力。
|
||||
:::
|
||||
|
||||
## 模块特点
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 基类 | BaseLogic(系统共享,不隔离) |
|
||||
| 关联 | 与 Menu 多对多(通过 RoleMenu 中间表) |
|
||||
| 数据权限 | data_scope 字段控制(1=全部 2=本部门 3=仅本人) |
|
||||
| 基类 | `BaseTenantService`(租户隔离,角色含 `tenant_id`) |
|
||||
| 关联 | 与 Menu 多对多(通过 `RoleMenu` 中间表) |
|
||||
| 唯一校验 | `code` 按租户分组唯一(`['code','tenant_id']`) |
|
||||
| 数据权限 | 范围由 `HasDataScope` 解析(当前表无 `data_scope` 字段,默认全部数据) |
|
||||
| 缓存联动 | 菜单授权后 `clearRolePermissions()` 清除该角色权限缓存(配合 `RoleMenuObserver`) |
|
||||
|
||||
## 套餐化角色预置
|
||||
|
||||
@@ -22,7 +24,7 @@
|
||||
| 租户-专业版 | 进阶功能 | 核心 + 高级模块 |
|
||||
| 租户-旗舰版 | 全功能 | 全部菜单(最全) |
|
||||
|
||||
创建租户后,通过「角色管理 → 授权菜单」为对应角色分配菜单权限。详见 [租户管理](/zh/modules/tenant.html)。
|
||||
创建租户后,通过「角色管理 → 授权菜单」为对应角色分配菜单权限。详见 [6.13 租户管理](/zh/modules/tenant.html)。
|
||||
|
||||
## API 接口
|
||||
|
||||
@@ -30,154 +32,123 @@
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/role/page` | sys:role:page | 分页列表 |
|
||||
| GET | `/api/role/list` | sys:role:list | 全量列表 |
|
||||
| GET | `/api/role/detail/:id` | sys:role:detail | 详情 |
|
||||
| GET | `/api/role/detail/{id}` | sys:role:detail | 详情 |
|
||||
| POST | `/api/role/add` | sys:role:add | 新增 |
|
||||
| PUT | `/api/role/update` | sys:role:update | 修改 |
|
||||
| DELETE | `/api/role/delete/:id` | sys:role:delete | 删除 |
|
||||
| DELETE | `/api/role/delete/{id}` | sys:role:delete | 删除 |
|
||||
| DELETE | `/api/role/batchDelete` | sys:role:batchDelete | 批量删除 |
|
||||
| GET | `/api/role/menu/list/:id` | - | 获取角色菜单 |
|
||||
| POST | `/api/role/menu/save` | sys:role:authorize | 保存角色菜单 |
|
||||
| GET | `/api/role/menu/list/{id}` | — | 获取角色菜单(含选中状态) |
|
||||
| POST | `/api/role/menu/save` | sys:role:savePermission | 保存角色菜单 |
|
||||
|
||||
## Service 属性配置
|
||||
|
||||
```php
|
||||
// app/Services/RoleService.php(来自项目实际代码)
|
||||
|
||||
class RoleService extends BaseTenantService
|
||||
{
|
||||
// HasDataScope(数据权限 + 租户隔离)由 BaseTenantService 继承提供
|
||||
use HasCrudOperations, HasQueryScopes, HasUniqueCheck;
|
||||
|
||||
protected string $modelClass = Role::class;
|
||||
|
||||
protected array $pageLikeFields = ['name', 'code'];
|
||||
protected array $pageEqFields = [];
|
||||
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
|
||||
|
||||
// code 在同一租户内唯一
|
||||
protected array $uniqueFields = [['code', 'tenant_id']];
|
||||
}
|
||||
```
|
||||
|
||||
## 菜单权限分配
|
||||
|
||||
### 专用方法
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `getMenuListWithChecked($roleId)` | 获取平铺菜单列表(`id` / `name` / `parent_id` / `checked`),供前端授权弹窗回显 |
|
||||
| `saveMenus($roleId, $menuIds)` | **全量覆盖式**保存角色菜单(事务保护) |
|
||||
| `getMenuIds($roleId)` | 获取角色已有菜单 ID 数组 |
|
||||
| `clearRolePermissions($roleId)` | 清除该角色的权限缓存(授权即时生效) |
|
||||
|
||||
### 保存流程(saveMenus)
|
||||
|
||||
```text
|
||||
saveMenus($roleId, $menuIds)
|
||||
│
|
||||
▼
|
||||
DB::transaction {
|
||||
1. 删除该角色原有全部 role_menu 关联(全量覆盖)
|
||||
2. 遍历新菜单 ID,组装关联记录(含审计字段)
|
||||
3. 批量写入 role_menu
|
||||
4. clearRolePermissions() 清除该角色权限缓存
|
||||
}
|
||||
│ 任一步骤异常 → 自动回滚
|
||||
▼
|
||||
返回成功
|
||||
```
|
||||
|
||||
:::tip 授权即时生效
|
||||
保存成功即清除该角色在 Redis(`perms:{uid}`)与 Laravel Cache 中的权限缓存,拥有该角色的用户**下一次请求**即按新权限校验,无需重新登录。
|
||||
:::
|
||||
|
||||
### 控制器(menuSave)
|
||||
|
||||
```php
|
||||
// app/logic/RoleLogic.php
|
||||
// app/Http/Controllers/Api/RoleController.php(行为说明)
|
||||
|
||||
/**
|
||||
* 保存角色菜单关联(事务保护)
|
||||
*
|
||||
* 处理流程:
|
||||
* 1. 开启事务
|
||||
* 2. 先删除该角色原有的全部菜单关联(全量覆盖式保存)
|
||||
* 3. 遍历新菜单 ID,组装关联记录
|
||||
* 4. 批量写入关联记录
|
||||
* 5. 提交事务
|
||||
*/
|
||||
public function saveMenus(int $roleId, array $menuIds): bool
|
||||
#[OperationLog('角色管理-保存菜单权限', OperationLog::TYPE_AUTHORIZE, '保存角色ID:{roleId}的菜单权限')]
|
||||
#[Permission('sys:role:savePermission', '授权角色菜单')]
|
||||
public function menuSave(): JsonResponse
|
||||
{
|
||||
Db::startTrans();
|
||||
try {
|
||||
RoleMenu::where('role_id', $roleId)->delete();
|
||||
|
||||
$data = [];
|
||||
foreach ($menuIds as $menuId) {
|
||||
$data[] = [
|
||||
'role_id' => $roleId,
|
||||
'menu_id' => $menuId,
|
||||
'create_user' => request()->userInfo->username ?? '',
|
||||
'create_time' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($data)) {
|
||||
(new RoleMenu())->saveAll($data);
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
throw $e;
|
||||
$data = $this->getJsonBody();
|
||||
$roleId = $data['roleId'] ?? 0;
|
||||
$menuIds = $data['menuIds'] ?? [];
|
||||
if (empty($roleId)) {
|
||||
return $this->fail('缺少roleId参数');
|
||||
}
|
||||
|
||||
$this->roleService->saveMenus($roleId, $menuIds);
|
||||
|
||||
return $this->success(null, '保存成功');
|
||||
}
|
||||
```
|
||||
|
||||
### Controller 代码
|
||||
**请求参数:**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `roleId` | int | 是 | 角色 ID |
|
||||
| `menuIds` | array | 是 | 菜单 ID 数组(含目录/菜单/按钮) |
|
||||
|
||||
## 删除保护(beforeDelete / afterDelete 钩子)
|
||||
|
||||
```php
|
||||
// app/controller/RoleController.php
|
||||
|
||||
class RoleController extends BaseController
|
||||
// 删除前:校验业务约束(如角色已被用户使用时阻止删除)
|
||||
protected function beforeDelete(int $id): void
|
||||
{
|
||||
protected RoleLogic $logic;
|
||||
// 检查 user_role 是否存在关联 → 存在则抛 BizException 阻止删除
|
||||
}
|
||||
|
||||
protected function initialize(): void
|
||||
{
|
||||
$this->logic = new RoleLogic();
|
||||
}
|
||||
|
||||
#[Log('角色管理-查询分页记录', Log::TYPE_QUERY)]
|
||||
#[Permission('sys:role:page', '角色分页')]
|
||||
public function page(): Json
|
||||
{
|
||||
return parent::_index($this->logic);
|
||||
}
|
||||
|
||||
#[Log('角色管理-查询全量列表', Log::TYPE_QUERY)]
|
||||
public function list(): Json
|
||||
{
|
||||
return parent::_list($this->logic);
|
||||
}
|
||||
|
||||
#[Log('角色管理-查询详情', Log::TYPE_QUERY, '查询角色ID:{id}')]
|
||||
public function detail(int $id): Json
|
||||
{
|
||||
return parent::_detail($this->logic, $id);
|
||||
}
|
||||
|
||||
#[Log('角色管理-新增记录', Log::TYPE_ADD, '新增角色:{name}')]
|
||||
#[Permission('sys:role:add', '添加角色')]
|
||||
public function add(): Json
|
||||
{
|
||||
return parent::_add($this->logic, $this->getJsonBody());
|
||||
}
|
||||
|
||||
#[Log('角色管理-修改记录', Log::TYPE_UPDATE, '修改角色ID:{id}')]
|
||||
#[Permission('sys:role:update', '修改角色')]
|
||||
public function update(): Json
|
||||
{
|
||||
$data = $this->getJsonBody();
|
||||
$id = $data['id'] ?? 0;
|
||||
if (empty($id)) {
|
||||
return $this->fail('缺少id参数');
|
||||
}
|
||||
return parent::_edit($this->logic, $id, $data);
|
||||
}
|
||||
|
||||
#[Log('角色管理-删除记录', Log::TYPE_DELETE, '删除角色ID:{id}')]
|
||||
#[Permission('sys:role:delete', '删除角色')]
|
||||
public function delete(int $id): Json
|
||||
{
|
||||
return parent::_remove($this->logic, $id);
|
||||
}
|
||||
|
||||
#[Log('角色管理-批量删除记录', Log::TYPE_DELETE)]
|
||||
#[Permission('sys:role:delete', '删除角色')]
|
||||
public function batchDelete(): Json
|
||||
{
|
||||
$ids = $this->getJsonBody()['ids'] ?? [];
|
||||
return parent::_batchRemove($this->logic, $ids);
|
||||
}
|
||||
|
||||
// 获取角色菜单(带选中状态)
|
||||
#[Log('角色管理-查询菜单权限', Log::TYPE_QUERY, '查询角色ID:{id}的菜单权限')]
|
||||
public function menuList(int $id): Json
|
||||
{
|
||||
$result = $this->logic->getMenuListWithChecked($id);
|
||||
return $this->success($result);
|
||||
}
|
||||
|
||||
// 保存角色菜单
|
||||
#[Log('角色管理-保存菜单权限', Log::TYPE_AUTHORIZE, '保存角色ID:{roleId}的菜单权限')]
|
||||
#[Permission('sys:role:authorize', '授权角色菜单')]
|
||||
public function menuSave(): Json
|
||||
{
|
||||
$data = $this->getJsonBody();
|
||||
$roleId = $data['roleId'] ?? 0;
|
||||
$menuIds = $data['menuIds'] ?? [];
|
||||
if (empty($roleId)) {
|
||||
return $this->fail('缺少roleId参数');
|
||||
}
|
||||
try {
|
||||
$this->logic->saveMenus($roleId, $menuIds);
|
||||
return $this->success(null, '保存成功');
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
// 删除后:清理 role_menu 关联 + 清除权限缓存
|
||||
protected function afterDelete(int $id): void
|
||||
{
|
||||
RoleMenu::where('role_id', $id)->delete();
|
||||
$this->clearRolePermissions($id);
|
||||
}
|
||||
```
|
||||
|
||||
:::warning 权限生效
|
||||
权限分配后,拥有该角色的用户需重新登录才能获取最新权限。权限变更时相关缓存会自动失效。
|
||||
:::
|
||||
## 数据权限
|
||||
|
||||
数据范围语义由 `HasDataScope` 解析(详见 [5.9 数据权限](/zh/core/data-scope.html)):
|
||||
|
||||
| 值 | 范围 |
|
||||
|----|------|
|
||||
| 1 | 全部数据(当前默认) |
|
||||
| 2 | 本部门数据 |
|
||||
| 3 | 仅本人数据(无角色用户默认) |
|
||||
|
||||
:::info 当前版本现状
|
||||
`laravel_role` 表不包含 `data_scope` 字段(按当前表设计),`getRoleDataScope()` 读取缺省为 1——角色一律按全部数据处理。
|
||||
:::
|
||||
|
||||
@@ -1,172 +1,188 @@
|
||||
# 6.13 租户管理
|
||||
|
||||
:::tip 概述
|
||||
租户管理是多租户体系的核心,支持租户的 CRUD、创建租户账号、查看租户用户。继承 BaseLogic(系统共享,租户表本身不隔离)。
|
||||
租户管理是多租户体系的核心,支持租户的 CRUD、创建租户账号、查看租户用户。`TenantService` 继承 `BaseService`(租户表本身不做租户隔离),并通过悲观锁生成租户编码。
|
||||
:::
|
||||
|
||||
## 模块特点
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 基类 | BaseLogic(系统共享,租户表不隔离) |
|
||||
| 特殊接口 | account(创建租户账号)、users(查看租户用户) |
|
||||
| 删除校验 | beforeDelete 检查用户数 + 保护默认租户 |
|
||||
| 编码生成 | afterAdd 自动生成租户编码(T+4位ID) |
|
||||
| 状态校验 | TenantMiddleware 校验租户状态和过期时间 |
|
||||
| 基类 | `BaseService`(系统共享,租户表不隔离) |
|
||||
| Trait 组合 | `HasCrudOperations, HasFileProcessing, HasQueryScopes, HasSerializeMaps, HasUniqueCheck` |
|
||||
| 文件字段 | `logo`(租户 Logo,保存到 tenant 目录) |
|
||||
| 唯一校验 | `code` 全局唯一 |
|
||||
| 枚举映射 | `status` → tenant_status 字典 |
|
||||
| 特殊接口 | `account`(创建租户账号)、`users/{tenantId}`(查看租户用户) |
|
||||
| 编码生成 | `beforeAdd` 悲观锁生成租户编码(T+4位序号,如 T0001) |
|
||||
| 编码保护 | `beforeUpdate` 禁止修改编码 |
|
||||
| 删除校验 | `beforeDelete` 检查租户下是否存在用户 |
|
||||
| 列表补全 | `afterPageList` 批量补充 `userCount`(避免 N+1) |
|
||||
|
||||
## API 接口
|
||||
|
||||
| 方法 | 路径 | 权限码 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/tenant/page` | sys:tenant:page | 分页列表 |
|
||||
| GET | `/api/tenant/page` | sys:tenant:page | 分页列表(含 userCount) |
|
||||
| GET | `/api/tenant/list` | sys:tenant:list | 全量列表 |
|
||||
| GET | `/api/tenant/detail/:id` | sys:tenant:detail | 详情 |
|
||||
| GET | `/api/tenant/users/:tenantId` | - | 租户用户列表 |
|
||||
| GET | `/api/tenant/detail/{id}` | sys:tenant:detail | 详情 |
|
||||
| GET | `/api/tenant/users/{tenantId}` | — | 租户用户列表 |
|
||||
| POST | `/api/tenant/add` | sys:tenant:add | 新增租户 |
|
||||
| POST | `/api/tenant/account` | sys:tenant:add | 创建租户账号 |
|
||||
| PUT | `/api/tenant/update` | sys:tenant:update | 修改租户 |
|
||||
| DELETE | `/api/tenant/delete/:id` | sys:tenant:delete | 删除租户 |
|
||||
| DELETE | `/api/tenant/delete/{id}` | sys:tenant:delete | 删除租户 |
|
||||
| DELETE | `/api/tenant/batchDelete` | sys:tenant:batchDelete | 批量删除 |
|
||||
|
||||
## 核心代码
|
||||
## Service 配置与钩子(来自项目实际代码)
|
||||
|
||||
```php
|
||||
// app/logic/TenantLogic.php
|
||||
|
||||
class TenantLogic extends BaseLogic
|
||||
class TenantService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 关联的模型类
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
use HasCrudOperations, HasFileProcessing, HasQueryScopes, HasSerializeMaps, HasUniqueCheck;
|
||||
|
||||
protected string $modelClass = Tenant::class;
|
||||
|
||||
/**
|
||||
* LIKE 模糊匹配字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageLikeFields = ['name', 'code', 'contact'];
|
||||
protected array $fileFields = ['logo'];
|
||||
protected string $fileSaveDir = 'tenant';
|
||||
|
||||
/**
|
||||
* 精确匹配字段
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageLikeFields = ['name', 'code', 'contact'];
|
||||
protected array $pageEqFields = ['status'];
|
||||
|
||||
/**
|
||||
* 默认排序规则
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageOrderBy = ['field' => 'id', 'type' => 'desc'];
|
||||
protected array $uniqueFields = ['code'];
|
||||
|
||||
/**
|
||||
* 枚举显示名映射
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $serializeMaps = ['status' => 'tenant_status'];
|
||||
protected array $serializeMaps = [
|
||||
'status' => 'tenant_status',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
/**
|
||||
* 修改前处理:禁止修改编码
|
||||
*
|
||||
* @param int $id 租户 ID
|
||||
* @param array $data 待更新的数据
|
||||
* @return array 过滤后的数据
|
||||
*/
|
||||
protected function beforeUpdate(int $id, array $data): array
|
||||
{
|
||||
// 租户编码由系统自动生成(T+4位ID),不允许手动修改
|
||||
unset($data['code']);
|
||||
return $data;
|
||||
### 编码生成(beforeAdd 钩子)
|
||||
|
||||
```php
|
||||
/**
|
||||
* 新增前钩子:自动生成租户编码
|
||||
*
|
||||
* 租户编码格式:T + 4位序号左补零(如 T0001、T0002)。
|
||||
* 通过悲观锁获取当前最大编码并递增,避免并发重号。
|
||||
* 编码在创建前生成,一次 SQL 即可完成,无需回写。
|
||||
*/
|
||||
protected function beforeAdd(array $data): array
|
||||
{
|
||||
// 使用悲观锁防止并发生成相同编码
|
||||
$lastCode = Tenant::withoutGlobalScopes()
|
||||
->lockForUpdate()
|
||||
->orderBy('id', 'desc')
|
||||
->value('code');
|
||||
$nextSeq = 1;
|
||||
if ($lastCode && preg_match('/^T(\d+)$/', $lastCode, $m)) {
|
||||
$nextSeq = (int) $m[1] + 1;
|
||||
}
|
||||
$data['code'] = 'T'.str_pad((string) $nextSeq, 4, '0', STR_PAD_LEFT);
|
||||
|
||||
/**
|
||||
* 新增后处理:自动生成租户编码
|
||||
*
|
||||
* @param int $id 新增的租户 ID
|
||||
* @param array $data 新增时提交的原始数据
|
||||
* @return void
|
||||
*/
|
||||
protected function afterAdd(int $id, array $data): void
|
||||
{
|
||||
// 编码格式:T + 4位ID左补零,如 T0001、T0002;因为依赖自增ID,所以在新增完成后回写
|
||||
$code = 'T' . str_pad((string)$id, 4, '0', STR_PAD_LEFT);
|
||||
Tenant::where('id', $id)->update(['code' => $code]);
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
```
|
||||
|
||||
/**
|
||||
* 删除前处理:保护默认租户 + 检查用户数
|
||||
*
|
||||
* @param int $id 待删除的租户 ID
|
||||
* @return void
|
||||
* @throws \Exception 当为默认租户或存在用户时抛出
|
||||
*/
|
||||
protected function beforeDelete(int $id): void
|
||||
{
|
||||
// 默认租户不可删除
|
||||
if ($id == 1) {
|
||||
throw new \Exception('默认租户不可删除');
|
||||
}
|
||||
$userCount = User::where('tenant_id', $id)->where('is_delete', 0)->count();
|
||||
if ($userCount > 0) {
|
||||
throw new \Exception('该租户下存在用户,不可删除');
|
||||
}
|
||||
}
|
||||
:::tip 悲观锁防重号
|
||||
多管理员同时创建租户时,`lockForUpdate()` 在事务内锁定最大编码读取,保证序号递增不重号——生成发生在创建前,无需像"回写编码"方案那样二次 UPDATE。
|
||||
:::
|
||||
|
||||
/**
|
||||
* 列表查询后处理:批量补充租户用户数
|
||||
*
|
||||
* @param array &$records 记录列表(引用传递)
|
||||
* @param array $params 查询参数
|
||||
* @return void
|
||||
*/
|
||||
protected function afterPageList(array &$records, array $params): void
|
||||
{
|
||||
if (empty($records)) return;
|
||||
### 编码保护(beforeUpdate 钩子)
|
||||
|
||||
// 一次性查询当前页所有租户的用户数量,避免 N+1
|
||||
$tenantIds = array_column($records, 'id');
|
||||
$counts = User::where('is_delete', 0)
|
||||
->whereIn('tenant_id', $tenantIds)
|
||||
->group('tenant_id')
|
||||
->column('count(*)', 'tenant_id');
|
||||
```php
|
||||
protected function beforeUpdate(int $id, array $data): array
|
||||
{
|
||||
unset($data['code']); // 租户编码由系统自动生成,不允许手动修改
|
||||
return $data;
|
||||
}
|
||||
```
|
||||
|
||||
foreach ($records as &$record) {
|
||||
$record['userCount'] = $counts[$record['id']] ?? 0;
|
||||
}
|
||||
### 删除保护(beforeDelete 钩子)
|
||||
|
||||
```php
|
||||
protected function beforeDelete(int $id): void
|
||||
{
|
||||
// 存在关联用户时不允许删除
|
||||
$hasUsers = User::where('tenant_id', $id)->exists();
|
||||
if ($hasUsers) {
|
||||
throw new BizException('该租户下存在用户,不可删除');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 列表返回 userCount
|
||||
|
||||
`/api/tenant/page` 和 `/api/tenant/list` 接口返回的每条记录中自动包含 `userCount` 字段,表示该租户下的用户数量。
|
||||
|
||||
:::tip 实现方式
|
||||
通过 `BaseLogic::afterPageList` 钩子实现,使用 `whereIn` + `group` 批量查询,避免 N+1 问题。
|
||||
:::tip 默认租户保护
|
||||
默认租户(id=1)下必然存在用户(平台数据归属),因此用户数校验天然阻止删除默认租户。超级管理员(uid=1)不受租户隔离限制,创建租户隔离数据时自动代入默认租户(见 [5.8 多租户隔离](/zh/core/tenant.html))。
|
||||
:::
|
||||
|
||||
### 创建租户账号
|
||||
### 列表补全 userCount(afterPageList 钩子)
|
||||
|
||||
```php
|
||||
// TenantLogic::createAccount()
|
||||
// 处理流程:
|
||||
// 1. 校验租户存在 + 用户数限制
|
||||
// 2. 注入租户ID,调用 UserLogic::add() 完成标准创建
|
||||
// (含编码自动生成、密码加密、头像处理、角色关联)
|
||||
protected function afterPageList(array &$records, array $params): void
|
||||
{
|
||||
$tenantIds = array_column($records, 'id');
|
||||
if (empty($tenantIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 批量获取租户用户数:一次查询按 tenant_id 分组统计
|
||||
$userCounts = User::whereIn('tenant_id', $tenantIds)
|
||||
->where('is_delete', 0)
|
||||
->selectRaw('tenant_id, count(*) as cnt')
|
||||
->groupBy('tenant_id')
|
||||
->pluck('cnt', 'tenant_id')
|
||||
->toArray();
|
||||
|
||||
foreach ($records as &$item) {
|
||||
$item['userCount'] = $userCounts[$item['id']] ?? 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
:::tip 用户编码由 UserLogic 统一生成
|
||||
创建租户账号时不再由 TenantLogic 直接生成用户编码,
|
||||
而是通过 UserLogic::beforeAdd 统一生成,确保所有用户创建路径(直接添加、租户账号、导入)编码规则一致。
|
||||
:::
|
||||
`/api/tenant/page` 和 `/api/tenant/list` 返回的每条记录自动包含 `userCount` 字段。
|
||||
|
||||
:::warning 默认租户保护
|
||||
默认租户(id=1)不可删除,编码不可修改。超级管理员(tenant_id=0)不受租户隔离限制,创建租户隔离数据时自动代入默认租户(tenant_id=1)。详见 [多租户隔离](/zh/core/tenant.html)。
|
||||
## 租户用户接口
|
||||
|
||||
| 方法/服务 | 说明 |
|
||||
|------|------|
|
||||
| `GET /api/tenant/users/{tenantId}` | 租户用户列表(`getUsers()`:仅返回 id/username/realname/mobile/status/create_time 展示字段,不带敏感信息) |
|
||||
| `getUserCount($tenantId)` | 租户用户数(供统计使用) |
|
||||
|
||||
## 创建租户账号(POST /api/tenant/account)
|
||||
|
||||
```php
|
||||
/**
|
||||
* 创建租户管理员账号
|
||||
*
|
||||
* 流程:校验租户 → 用户数上限校验 → 用户名全局唯一 → 组装角色 → 事务内委托
|
||||
* UserService::add() 完成标准创建。编码生成、密码加密、城市拆分、
|
||||
* 头像迁移与角色关联由其生命周期钩子完成。
|
||||
* 未传 password 时使用默认密码。
|
||||
*/
|
||||
public function createAccount(array $data): int { /* ... */ }
|
||||
```
|
||||
|
||||
**请求参数:**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `tenantId` | int | 是 | 所属租户 |
|
||||
| `username` | string | 是 | 登录用户名 |
|
||||
| `realname` | string | 是 | 姓名 |
|
||||
| `password` | string | 否 | 密码(缺省用默认密码) |
|
||||
| `mobile` / `email` | string | 否 | 手机号 / 邮箱 |
|
||||
| `roles` | array | 否 | 角色ID数组(表单多选,推荐) |
|
||||
| `roleId` | int | 否 | 绑定角色(单角色兼容传参) |
|
||||
| `gender` | int | 否 | 性别:1=男 2=女 3=保密 |
|
||||
| `avatar` | string | 否 | 头像地址(正式路径,入库时去域名) |
|
||||
| `deptId` / `levelId` / `positionId` | int | 否 | 部门 / 职级 / 岗位 ID |
|
||||
| `city` | array | 是 | 省/市/区/街道四级编码数组 |
|
||||
| `address` | string | 是 | 详细地址 |
|
||||
| `status` | int | 否 | 状态:1=正常 2=禁用(默认 1) |
|
||||
| `sort` | int | 否 | 排序值 |
|
||||
| `intro` / `note` | string | 否 | 简介 / 备注 |
|
||||
|
||||
:::tip 编码规则与用户模块一致
|
||||
租户账号编码按 `租户编码-6位序号` 规则生成(如 `T0001-000001`),由标准创建流程的 `beforeAdd` 钩子统一生成——所有创建路径(直接添加、租户账号、导入)编码格式相同。
|
||||
:::
|
||||
|
||||
## 套餐化角色预置
|
||||
@@ -179,7 +195,7 @@ class TenantLogic extends BaseLogic
|
||||
| 租户-专业版 | 进阶功能 | 核心 + 高级模块 |
|
||||
| 租户-旗舰版 | 全功能 | 全部菜单(最全) |
|
||||
|
||||
**使用方式:** 创建租户后,通过「角色管理 → 授权菜单」为对应角色分配菜单权限。创建租户管理员账号时,选择对应套餐角色即可完成授权。
|
||||
**使用方式:** 创建租户后,通过「角色管理 → 授权菜单」为对应角色分配菜单权限;创建租户管理员账号(`tenant/account`)时选择对应套餐角色即可完成授权。
|
||||
|
||||
:::tip 设计意图
|
||||
套餐角色解决了"每个租户都要手动配一遍菜单"的重复工作。管理员创建租户时只需选择套餐,角色权限即刻生效。
|
||||
@@ -210,5 +226,7 @@ class TenantLogic extends BaseLogic
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `status` | 0=禁用 1=启用 |
|
||||
| `expire_time` | 过期时间,过期后 TenantMiddleware 返回 403 |
|
||||
| `max_user_num` | 最大用户数限制 |
|
||||
| `expire_time` | 过期时间,过期后 `ResolveTenant` 返回 403「租户已过期」 |
|
||||
| `max_user_num` | 最大用户数限制(创建账号时校验) |
|
||||
|
||||
租户状态与有效期由 `ResolveTenant` 中间件在每个请求上校验,详见 [5.8 多租户隔离](/zh/core/tenant.html)。
|
||||
|
||||
@@ -1,64 +1,117 @@
|
||||
# 6.1 用户管理
|
||||
|
||||
:::tip 概述
|
||||
用户管理是系统核心模块,继承 BaseTenantLogic 实现租户隔离,支持 CRUD、重置密码、Excel 导入导出。
|
||||
用户管理是系统核心模块,`UserService` 继承 `BaseTenantService` 实现租户隔离,支持 CRUD、重置密码、Excel 导入导出,并通过钩子完成编码生成、密码加密、角色关联等业务。
|
||||
:::
|
||||
|
||||
## 模块特点
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 租户隔离 | 继承 BaseTenantLogic,自动按 tenant_id 过滤 |
|
||||
| 数据权限 | 配置 dataScopeDeptField='dept_id',支持按部门过滤 |
|
||||
| 编码生成 | beforeAdd 自动生成用户编码(租户编码-6位序号,如 T0001-000020) |
|
||||
| 文件字段 | avatar(头像),保存到 user/avatar 目录 |
|
||||
| 唯一校验 | username、mobile 全局唯一 |
|
||||
| 枚举映射 | gender→gender 字典,status→user_status 字典 |
|
||||
| 密码加密 | bcrypt 双重加盐,新增时自动加密 |
|
||||
| 角色关联 | 通过 UserRole 中间表关联,支持多角色 |
|
||||
| 租户隔离 | 继承 `BaseTenantService`,自动按 `tenant_id` 过滤与填充 |
|
||||
| 数据权限 | `dataScopeDeptField='dept_id'`,支持按部门过滤 |
|
||||
| 编码生成 | `beforeAdd` 自动生成用户编码(租户编码-6位序号,如 `T0001-000020`) |
|
||||
| 唯一校验 | `username` 全局唯一;`mobile` **按租户分组唯一**(`['mobile','tenant_id']`) |
|
||||
| 枚举映射 | `gender`→gender 字典,`status`→user_status 字典 |
|
||||
| 密码加密 | bcrypt 双重加盐,新增/重置时自动加密 |
|
||||
| 角色关联 | 通过 `UserRole` 中间表关联,支持多角色(`roles` 数组) |
|
||||
| 导入导出 | Excel 分批导出 + 表头映射导入(字典/部门/城市反查) |
|
||||
|
||||
## 文件清单
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `app/controller/UserController.php` | 控制器(9 个接口) |
|
||||
| `app/logic/UserLogic.php` | 逻辑层(含 import/export/resetPassword) |
|
||||
| `app/model/User.php` | 模型 |
|
||||
| `app/model/UserRole.php` | 用户角色关联模型 |
|
||||
| `app/validate/UserValidate.php` | 验证器 |
|
||||
| `app/Http/Controllers/Api/UserController.php` | 控制器(9 个接口) |
|
||||
| `app/Services/UserService.php` | 业务服务(含 import/export/resetPassword + 6 个钩子) |
|
||||
| `app/Models/User.php` | 模型 |
|
||||
| `app/Models/UserRole.php` | 用户角色关联模型 |
|
||||
| `app/Http/Requests/UserRequest.php` | 表单请求验证 |
|
||||
|
||||
## API 接口
|
||||
|
||||
| 方法 | 路径 | 权限码 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/user/page` | sys:user:page | 分页列表 |
|
||||
| GET | `/api/user/detail/:id` | sys:user:detail | 详情 |
|
||||
| GET | `/api/user/detail/{id}` | sys:user:detail | 详情 |
|
||||
| POST | `/api/user/add` | sys:user:add | 新增 |
|
||||
| PUT | `/api/user/update` | sys:user:update | 修改 |
|
||||
| PUT | `/api/user/resetPwd` | sys:user:resetPwd | 重置密码 |
|
||||
| DELETE | `/api/user/delete/:id` | sys:user:delete | 删除 |
|
||||
| DELETE | `/api/user/delete/{id}` | sys:user:delete | 删除 |
|
||||
| DELETE | `/api/user/batchDelete` | sys:user:batchDelete | 批量删除 |
|
||||
| POST | `/api/user/import` | sys:user:import | 导入 |
|
||||
| GET | `/api/user/export` | sys:user:export | 导出 |
|
||||
|
||||
## Service 属性配置
|
||||
|
||||
```php
|
||||
// app/Services/UserService.php(来自项目实际代码)
|
||||
|
||||
class UserService extends BaseTenantService
|
||||
{
|
||||
// HasDataScope(数据权限 + 租户隔离)由 BaseTenantService 继承提供
|
||||
// HasFileProcessing:avatar 头像字段的保存前迁移与读取后补域名
|
||||
use HasCrudOperations, HasFileProcessing, HasQueryScopes, HasSerializeMaps, HasUniqueCheck;
|
||||
|
||||
protected string $modelClass = User::class;
|
||||
|
||||
// 单文件字段:保存时迁移临时文件,读取时补全域名
|
||||
protected array $fileFields = ['avatar'];
|
||||
|
||||
// 文件保存子目录
|
||||
protected string $fileSaveDir = 'user';
|
||||
|
||||
// LIKE 模糊匹配字段
|
||||
protected array $pageLikeFields = ['username', 'realname', 'mobile'];
|
||||
|
||||
// 精确匹配字段
|
||||
protected array $pageEqFields = ['status', 'dept_id', 'gender', 'tenant_id'];
|
||||
|
||||
// 默认排序:先按租户分组,再按 id 倒序
|
||||
protected array $pageOrderBy = [
|
||||
['field' => 'tenant_id', 'type' => 'asc'],
|
||||
['field' => 'id', 'type' => 'desc'],
|
||||
];
|
||||
|
||||
// 唯一性校验:username 全局唯一;mobile 在同一租户内唯一
|
||||
protected array $uniqueFields = ['username', ['mobile', 'tenant_id']];
|
||||
|
||||
// 字典翻译
|
||||
protected array $serializeMaps = [
|
||||
'gender' => 'gender',
|
||||
'status' => 'user_status',
|
||||
];
|
||||
|
||||
// 数据权限:按部门过滤
|
||||
protected string $dataScopeDeptField = 'dept_id';
|
||||
}
|
||||
```
|
||||
|
||||
## 特殊逻辑
|
||||
|
||||
### 重置密码
|
||||
|
||||
```php
|
||||
// app/controller/UserController.php
|
||||
// app/Http/Controllers/Api/UserController.php(来自项目实际代码)
|
||||
|
||||
public function resetPwd(): Json
|
||||
public function resetPwd(): JsonResponse
|
||||
{
|
||||
$data = $this->getJsonBody();
|
||||
$id = $data['userId'] ?? 0;
|
||||
|
||||
if (empty($id)) {
|
||||
return $this->fail('缺少id参数');
|
||||
}
|
||||
|
||||
$this->logic->resetPassword($id);
|
||||
return $this->success(null, '密码重置成功');
|
||||
try {
|
||||
$this->userService->resetPassword($id);
|
||||
|
||||
return $this->success(null, '密码重置成功');
|
||||
} catch (BizException $e) {
|
||||
return $this->fail($e->getMessage(), $e->getCode());
|
||||
} catch (\Exception $e) {
|
||||
logger()->error('操作失败', ['error' => $e->getMessage()]);
|
||||
|
||||
return $this->fail('操作失败');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -68,50 +121,26 @@ public function resetPwd(): Json
|
||||
|------|------|------|------|
|
||||
| `userId` | int | 是 | 用户ID |
|
||||
|
||||
:::tip 默认密码
|
||||
重置密码接口无需传递密码参数,系统自动使用 `PasswordService::getDefaultPasswordPlain()` 作为默认密码。
|
||||
:::tip 默认密码与全端下线
|
||||
重置密码接口无需传递密码参数,系统自动使用默认密码(系统参数 `DEFAULT_PASSWORD`,默认 `123456`)加密写入。`UserService::resetPassword()` 成功后会自动调用 `JwtService::revokeAllTokens($id)` 吊销该用户全部令牌(见 [5.6 密码加密服务](/zh/core/password.html))。
|
||||
:::
|
||||
|
||||
### 新增用户(beforeAdd 钩子)
|
||||
|
||||
```php
|
||||
// app/logic/UserLogic.php
|
||||
// app/Services/UserService.php(行为说明)
|
||||
|
||||
/**
|
||||
* 新增前处理:生成编码、处理城市、加密密码
|
||||
*
|
||||
* @param array $data 待新增的数据
|
||||
* @return array 处理后的数据
|
||||
*/
|
||||
protected function beforeAdd(array $data): array
|
||||
{
|
||||
// 自动生成用户编码:租户编码 + 租户内序号(如 T0001-000020)
|
||||
$tenantId = $data['tenantId'] ?? request()->tenantId ?? 0;
|
||||
if ($tenantId == 0) {
|
||||
$tenantId = config('tenant.default_tenant_id', 1);
|
||||
}
|
||||
if ($tenantId > 0) {
|
||||
$tenant = Tenant::find($tenantId);
|
||||
$currentUserCount = User::where('tenant_id', $tenantId)->where('is_delete', 0)->count();
|
||||
$nextSeq = $currentUserCount + 1;
|
||||
$data['code'] = $tenant->code . '-' . str_pad((string)$nextSeq, 6, '0', STR_PAD_LEFT);
|
||||
}
|
||||
// 1. 自动生成用户编码:租户编码 + 租户内序号(如 T0001-000020)
|
||||
// 序号 = 当前租户下未删除用户数 + 1
|
||||
// 平台用户(tenantId=0)自动代入默认租户生成编码
|
||||
|
||||
// 处理城市字段
|
||||
if (isset($data['city']) && is_array($data['city'])) {
|
||||
$data['province_code'] = $data['city'][0] ?? '';
|
||||
$data['city_code'] = $data['city'][1] ?? '';
|
||||
$data['district_code'] = $data['city'][2] ?? '';
|
||||
$data['street_code'] = $data['city'][3] ?? '';
|
||||
$data['city_info'] = City::resolveCityNames($data['city']);
|
||||
unset($data['city']);
|
||||
}
|
||||
// 2. 处理城市字段:city 数组拆为 province_code / city_code /
|
||||
// district_code / street_code 四个编码 + city_info 名称拼接
|
||||
|
||||
// 密码加密
|
||||
$password = $data['password'] ?? PasswordService::getDefaultPasswordPlain();
|
||||
$encrypted = PasswordService::encrypt($password);
|
||||
$data['password'] = $encrypted['password'];
|
||||
$data['salt'] = $encrypted['salt'];
|
||||
// 3. 密码加密:PasswordService::encrypt()(未传密码时用默认密码)
|
||||
// → $data['password'] = 哈希; $data['salt'] = 外部盐
|
||||
|
||||
return $data;
|
||||
}
|
||||
@@ -120,91 +149,77 @@ protected function beforeAdd(array $data): array
|
||||
:::tip 编码规则
|
||||
- 用户编码格式:`租户编码-6位序号`,如 `T0001-000020`
|
||||
- 序号 = 当前租户下未删除用户数 + 1
|
||||
- 超级管理员(tenantId=0)自动代入默认租户生成编码
|
||||
- 编码由系统自动生成,前端不传,修改时不可变更
|
||||
:::
|
||||
|
||||
### 新增后保存角色(afterAdd 钩子)
|
||||
### 角色关联(afterAdd / afterUpdate 钩子)
|
||||
|
||||
```php
|
||||
/**
|
||||
* 新增后处理:保存用户角色关联
|
||||
*
|
||||
* @param int $id 新增用户的主键 ID
|
||||
* @param array $data 新增时提交的原始数据
|
||||
* @return void
|
||||
*/
|
||||
// afterAdd:新增后保存用户角色关联
|
||||
protected function afterAdd(int $id, array $data): void
|
||||
{
|
||||
$roleIds = $data['roles'] ?? [];
|
||||
foreach ($roleIds as $roleId) {
|
||||
UserRole::create([
|
||||
'user_id' => $id,
|
||||
'role_id' => $roleId,
|
||||
'create_user' => request()->userInfo->username ?? '',
|
||||
]);
|
||||
UserRole::create(['user_id' => $id, 'role_id' => $roleId]);
|
||||
}
|
||||
}
|
||||
|
||||
// afterUpdate:先删后插实现全量覆盖
|
||||
```
|
||||
|
||||
:::info 多角色支持
|
||||
前端传 `roles: [1, 2, 3]` 数组,`afterAdd` 遍历写入 `UserRole` 中间表。`afterUpdate` 同理,先删后插实现全量覆盖。
|
||||
新增/修改时前端传 `roles: [1, 2, 3]` ID 数组,`afterAdd` 遍历写入 `UserRole` 中间表,`afterUpdate` 先删除旧关联再重建;详情接口返回 ID 数组供表单回显,分页列表返回 `[{id, name}]` 对象数组,表格可直接取 `name` 展示。
|
||||
:::
|
||||
|
||||
### 详情补全(afterDetail 钩子)
|
||||
|
||||
```php
|
||||
/**
|
||||
* 详情查询后处理:补充关联数据与格式化输出
|
||||
*
|
||||
* @param int $id 用户 ID
|
||||
* @param array &$data 详情数据(引用传递)
|
||||
* @return void
|
||||
*/
|
||||
// 详情查询后处理(引用传递)
|
||||
protected function afterDetail(int $id, array &$data): void
|
||||
{
|
||||
// 补充角色信息
|
||||
$roleIds = UserRole::where('user_id', $id)->column('role_id');
|
||||
$data['roles'] = $roleIds;
|
||||
// 补充角色 ID 数组
|
||||
$data['roles'] = UserRole::where('user_id', $id)->pluck('role_id');
|
||||
|
||||
// 补充关联名称
|
||||
$data['levelName'] = Level::getNameMap()[$data['level_id']] ?? '';
|
||||
$data['positionName'] = Position::getNameMap()[$data['position_id']] ?? '';
|
||||
$data['deptName'] = Dept::getNameMap()[$data['dept_id']] ?? '';
|
||||
// 补充关联名称(部门/职级/岗位显示名)
|
||||
$data['deptName'] = ...;
|
||||
$data['levelName'] = ...;
|
||||
$data['positionName'] = ...;
|
||||
|
||||
// 组装城市数组
|
||||
// 组装城市编码数组(供前端级联回显)
|
||||
$data['city'] = [
|
||||
$data['province_code'] ?? '',
|
||||
$data['city_code'] ?? '',
|
||||
$data['district_code'] ?? '',
|
||||
$data['street_code'] ?? '',
|
||||
$data['province_code'] ?? '', $data['city_code'] ?? '',
|
||||
$data['district_code'] ?? '', $data['street_code'] ?? '',
|
||||
];
|
||||
|
||||
// 移除敏感字段
|
||||
// 移除敏感字段:password / salt 绝不返回前端
|
||||
unset($data['password'], $data['salt']);
|
||||
}
|
||||
```
|
||||
|
||||
:::warning 敏感字段
|
||||
`password` 与 `salt` 在详情/列表输出时必须剔除——这也是日志安全(操作日志 param 排除 password/token)之外的第二道防线。
|
||||
:::
|
||||
|
||||
### 列表补全(afterPageList 钩子)
|
||||
|
||||
分页列表批量补充角色名、部门/职级/岗位名称与字典翻译(避免 N+1,见 [3.5 Service 层开发](/zh/guide/logic.html))。
|
||||
|
||||
### 导出(export)
|
||||
|
||||
导出为分批导出(chunkExport),每批 500 条,避免内存溢出。
|
||||
导出为分批导出(`ExcelService`),每批 500 条,避免内存溢出。
|
||||
|
||||
| 导出列 | 字段来源 |
|
||||
|--------|----------|
|
||||
| 用户编码 | code(系统自动生成) |
|
||||
| 姓名 | realname |
|
||||
| 性别 | genderText(DictService 自动转换) |
|
||||
| 手机号 | mobile |
|
||||
| 邮箱 | email |
|
||||
| 部门 | deptName(Dept::getNameMap 映射) |
|
||||
| 职级 | levelName(Level::getNameMap 映射) |
|
||||
| 岗位 | positionName(Position::getNameMap 映射) |
|
||||
| 城市 | cityInfo(4个城市编码拼接为名称字符串) |
|
||||
| 性别 | genderText(字典自动转换) |
|
||||
| 手机号 / 邮箱 | mobile / email |
|
||||
| 部门 / 职级 / 岗位 | deptName / levelName / positionName |
|
||||
| 城市 | cityInfo(四个城市编码拼接为名称) |
|
||||
| 地址 | address |
|
||||
| 用户名 | username |
|
||||
| 简介 | intro |
|
||||
| 状态 | statusText(DictService 自动转换) |
|
||||
| 备注 | note |
|
||||
| 状态 | statusText(字典自动转换) |
|
||||
| 创建时间 | create_time |
|
||||
|
||||
### 导入(import)
|
||||
|
||||
@@ -212,11 +227,13 @@ protected function afterDetail(int $id, array &$data): void
|
||||
|
||||
| 导入列 | 处理逻辑 |
|
||||
|--------|----------|
|
||||
| 性别/状态 | 非数字时 DictService::getValue 反查 |
|
||||
| 部门/职级/岗位 | 名称通过 array_flip(getNameMap) 反查 ID |
|
||||
| 城市 | cityInfo 通过 City::resolveCityCodes 拆为4个编码 |
|
||||
| 用户编码 | 忽略(由 beforeAdd 自动生成) |
|
||||
| 性别/状态 | 非数字时 `DictCacheService::getValue` 反查 |
|
||||
| 部门/职级/岗位 | 名称通过名称映射反查 ID |
|
||||
| 城市 | cityInfo 通过城市工具拆为 4 个编码 |
|
||||
| 用户编码 | 忽略(由 `beforeAdd` 自动生成) |
|
||||
|
||||
单条失败不中断整体,失败明细(行号 + 原因)汇总返回。
|
||||
|
||||
:::warning 导入不处理 code
|
||||
导入时即使 Excel 中有「用户编码」列也会被忽略,编码由 `beforeAdd` 自动生成。
|
||||
:::
|
||||
:::
|
||||
|
||||
Reference in New Issue
Block a user