签入版本

This commit is contained in:
2026-09-29 10:14:04 +08:00
parent 3e5fc01124
commit d6b552103d
1664 changed files with 298944 additions and 0 deletions
@@ -0,0 +1,179 @@
# 6.7 文章管理
:::tip 概述
文章管理模块支持富文本内容编辑、封面图上传、分类关联。继承 BaseTenantLogic 实现租户隔离,并配置了数据权限(按创建人过滤)。
:::
## 模块特点
| 特性 | 说明 |
|------|------|
| 基类 | BaseTenantLogic(租户隔离) |
| 文件字段 | cover(封面图),保存到 article 目录 |
| 富文本字段 | content(文章内容),自动处理临时文件 |
| 枚举映射 | status → article_status 字典(0=正常 1=下架) |
| 数据权限 | dataScopeUserField='create_user',按创建人过滤 |
| 模糊搜索 | title、author |
| 精确匹配 | category_id、status |
## API 接口
| 方法 | 路径 | 权限码 | 说明 |
|------|------|--------|------|
| GET | `/api/article/page` | sys:article:page | 分页列表 |
| 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/batchDelete` | sys:article:batchDelete | 批量删除 |
## 核心代码
```php
// app/logic/ArticleLogic.php
class ArticleLogic extends BaseTenantLogic
{
/**
* 关联的模型类
*
* @var string
*/
protected string $modelClass = Article::class;
/**
* 需要自动处理的文件上传字段
*
* @var array
*/
protected array $fileFields = ['cover'];
/**
* 文件保存的子目录
*
* @var string
*/
protected string $fileSaveDir = 'article';
/**
* LIKE 模糊匹配字段
*
* @var array
*/
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';
}
```
### Controller 代码
```php
// app/controller/ArticleController.php
class ArticleController extends BaseController
{
protected ArticleLogic $logic;
protected function initialize(): void
{
$this->logic = new ArticleLogic();
}
#[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);
}
}
```
:::tip 数据权限
文章模块配置了 `dataScopeUserField = 'create_user'`,普通用户只能看到自己创建的文章。管理员(data_scope=1)可看到全部文章。
:::
:::warning 富文本处理
content 字段配置为富文本字段后,新增/修改时会自动处理内容中的临时文件:解析 `<img>` 标签的 src,临时文件迁移到正式目录,替换为 `[IMG_URL]` 占位符。读取时自动替换为实际域名。
:::
@@ -0,0 +1,168 @@
# 6.10 分类管理
:::tip 概述
分类管理支持树形分类数据,可用于文章分类、商品分类等场景。继承 BaseTenantLogic 实现租户隔离。
:::
## 模块特点
| 特性 | 说明 |
|------|------|
| 基类 | BaseTenantLogic(租户隔离) |
| 唯一校验 | category_name + parent_id 复合唯一(同一父级下名称不可重复) |
| 删除校验 | beforeDelete 检查子分类和关联文章 |
| 模糊搜索 | category_name |
| 精确匹配 | parent_id |
## 数据库表结构
```sql
CREATE TABLE `think_category` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`category_name` varchar(100) NOT NULL COMMENT '分类名称',
`category_code` varchar(100) DEFAULT NULL COMMENT '分类编码',
`parent_id` int DEFAULT 0 COMMENT '上级ID',
`sort` int DEFAULT 0 COMMENT '排序',
`note` varchar(100) DEFAULT NULL COMMENT '分类备注',
`create_user` varchar(50) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT (now()) COMMENT '创建时间',
`update_user` varchar(50) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT (now()) COMMENT '更新时间',
`is_delete` int DEFAULT 0 COMMENT '删除标识:0-正常 1-已删除',
PRIMARY KEY (`id`)
) COMMENT = '文章分类表';
```
## API 接口
| 方法 | 路径 | 权限码 | 说明 |
|------|------|--------|------|
| GET | `/api/category/page` | sys:category:page | 分页列表 |
| 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/batchDelete` | sys:category:batchDelete | 批量删除 |
## 核心代码
```php
// app/logic/CategoryLogic.php
class CategoryLogic extends BaseTenantLogic
{
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'],
];
/**
* 唯一性校验字段
*
* 同一父级下分类名称不可重复
*
* @var array
*/
protected array $uniqueFields = [['category_name', 'parent_id']];
/**
* 删除前处理:检查子分类和关联文章
*
* @param int $id 待删除的分类 ID
* @return void
* @throws \Exception 当存在子分类或关联文章时抛出
*/
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('该分类下存在文章,不可删除');
}
}
}
```
### 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);
}
}
```
:::warning 复合唯一性
分类的唯一性校验使用复合格式 `[['category_name', 'parent_id']]`,表示同一父级下分类名称不可重复,但不同父级下可以有同名分类。
:::
@@ -0,0 +1,160 @@
# 6.9 行政区划管理
:::tip 概述
行政区划管理维护全国省市区街道四级数据,支持按父编码级联查询。继承 BaseLogic(系统共享,不隔离)。
:::
## 模块特点
| 特性 | 说明 |
|------|------|
| 基类 | BaseLogic(系统共享,不隔离) |
| 层级结构 | id/pid + parent_code 双重层级标识 |
| 特殊接口 | getCityList 按父编码查询、getParent 获取省份列表 |
| 详情钩子 | afterDetail 补全父级名称 |
## 数据库表结构
```sql
CREATE TABLE `think_city` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`city_code` varchar(6) NOT NULL COMMENT '城市区号',
`area_code` varchar(20) NOT NULL COMMENT '行政编码',
`parent_code` varchar(20) DEFAULT NULL COMMENT '上级行政编码',
`zip_code` varchar(6) NOT NULL COMMENT '邮政编码',
`level` int DEFAULT 0 COMMENT '城市级别:0-省份 1-城市 2-县区 3-街道',
`pid` int DEFAULT 0 COMMENT '上级城市ID',
`name` varchar(150) NOT NULL COMMENT '城市名称',
`short_name` varchar(150) NOT NULL COMMENT '城市简称',
`full_name` varchar(150) DEFAULT NULL COMMENT '城市全称',
`pinyin` varchar(150) DEFAULT NULL COMMENT '城市拼音',
`lng` varchar(150) DEFAULT NULL COMMENT '城市经度',
`lat` varchar(150) DEFAULT NULL COMMENT '城市纬度',
`create_user` varchar(50) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT (now()) COMMENT '创建时间',
`update_user` varchar(50) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT (now()) COMMENT '更新时间',
`is_delete` int DEFAULT 0 COMMENT '删除标识:0-正常 1-已删除',
PRIMARY KEY (`id`)
) COMMENT = '城市行政区划表';
```
## API 接口
| 方法 | 路径 | 权限码 | 说明 |
|------|------|--------|------|
| GET | `/api/city/list` | sys:city:page | 城市列表 |
| 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` | - | 根据父编码查询子级城市 |
## 核心代码
```php
// app/logic/CityLogic.php
class CityLogic extends BaseLogic
{
/**
* 关联的模型类
*
* @var string
*/
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'];
/**
* 详情查询后处理:补全父级名称
*
* @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 : '';
} else {
$data['parentName'] = '';
}
}
// 按父级行政编码获取子级城市
public function getCityListByParentCode(string $parentCode): array
{
return City::where('parent_code', $parentCode)
->order('id', 'asc')
->select()
->toArray();
}
// 获取省份列表(level=0)
public function getParentList(): array
{
return City::where('level', 0)
->order('id', 'asc')
->select()
->toArray();
}
}
```
## 前端级联选择
```typescript
// ui/src/api/data/city.ts
export function getCityList(parentCode: string) {
return http.request({
url: '/city/getCityList/' + parentCode,
method: 'GET',
});
}
// 省 → 市 → 区 → 街道 级联加载
// 选择省份后加载城市,选择城市后加载区县...
```
## 模型辅助方法
City 模型提供两个静态方法,供导入导出使用:
| 方法 | 用途 | 示例 |
|------|------|------|
| `resolveCityNames(codes)` | 编码→名称 | `['320000000000','320100000000']` → `'江苏省,南京市'` |
| `resolveCityCodes(info)` | 名称→编码 | `'江苏省,南京市,建邺区'` → `['320000000000','320100000000','320105000000','']` |
```php
// 导出时:城市编码拼为名称字符串
$cityInfo = City::resolveCityNames(['320000000000', '320100000000', '320105000000']);
// 结果: "江苏省,南京市,建邺区"
// 导入时:城市名称反查为编码数组(逐级匹配,避免同名歧义)
$codes = City::resolveCityCodes('江苏省,南京市,建邺区');
// 结果: ['320000000000', '320100000000', '320105000000', '']
```
@@ -0,0 +1,190 @@
# 6.8 公告与链接管理
:::tip 概述
本节涵盖通知公告和友情链接两个模块,均继承 BaseTenantLogic 实现租户隔离。
> **说明:** 本文件因历史原因命名为 `content.md`,实际文档内容为通知公告(Notice)和友情链接(Link)两个独立模块,并不存在 "Content" 模块。
:::
## 通知公告
### 模块特点
| 特性 | 说明 |
|------|------|
| 基类 | BaseTenantLogic(租户隔离) |
| 文件字段 | cover(封面图),保存到 notice 目录 |
| 富文本字段 | content(公告内容) |
| 枚举映射 | type → notice_type(1=通知 2=公告),status → notice_status |
| 排序 | 按 id 倒序(最新在前) |
### 核心代码
```php
// app/logic/NoticeLogic.php
class NoticeLogic extends BaseTenantLogic
{
/**
* 关联的模型类
*
* @var string
*/
protected string $modelClass = Notice::class;
/**
* 需要自动处理的文件上传字段
*
* @var array
*/
protected array $fileFields = ['cover'];
/**
* 文件保存的子目录
*
* @var string
*/
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'],
];
/**
* 枚举显示名映射
*
* @var array
*/
protected array $serializeMaps = [
'type' => 'notice_type',
'status' => 'notice_status',
];
/**
* 富文本字段
*
* @var array
*/
protected array $contentFields = ['content'];
}
```
### API 接口
| 方法 | 路径 | 权限码 | 说明 |
|------|------|--------|------|
| GET | `/api/notice/page` | sys:notice:page | 分页列表 |
| 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/batchDelete` | sys:notice:batchDelete | 批量删除 |
---
## 友情链接
### 模块特点
| 特性 | 说明 |
|------|------|
| 基类 | BaseTenantLogic(租户隔离) |
| 文件字段 | image(友链Logo),保存到 link 目录 |
| 枚举映射 | type → link_type,form → link_form,status → link_status |
### 核心代码
```php
// app/logic/LinkLogic.php
class LinkLogic extends BaseTenantLogic
{
/**
* 关联的模型类
*
* @var string
*/
protected string $modelClass = Link::class;
/**
* 需要自动处理的文件上传字段
*
* @var array
*/
protected array $fileFields = ['image'];
/**
* 文件保存的子目录
*
* @var string
*/
protected string $fileSaveDir = 'link';
/**
* LIKE 模糊匹配字段
*
* @var array
*/
protected array $pageLikeFields = ['name'];
/**
* 精确匹配字段
*
* @var array
*/
protected array $pageEqFields = ['type', 'status'];
/**
* 默认排序规则
*
* @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',
];
}
```
### API 接口
| 方法 | 路径 | 权限码 | 说明 |
|------|------|--------|------|
| GET | `/api/link/page` | sys:link:page | 分页列表 |
| 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 | 批量删除 |
@@ -0,0 +1,145 @@
# 6.4 部门管理
:::tip 概述
部门管理维护组织架构数据,支持树形结构,是数据权限(按部门过滤)的基础。继承 BaseTenantLogic 实现租户隔离。
:::
## 模块特点
| 特性 | 说明 |
|------|------|
| 基类 | BaseTenantLogic(租户隔离) |
| 树形结构 | treeParentField='parent_id' |
| 唯一校验 | name 全局唯一 |
| 删除校验 | beforeDelete 检查是否存在子部门 |
| 数据权限 | 被 UserLogic 的 dataScopeDeptField='dept_id' 引用 |
## 数据库表结构
```sql
CREATE TABLE `think_dept` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(150) NOT NULL COMMENT '部门名称',
`type` int DEFAULT 0 COMMENT '部门类型:1-公司 2-子公司 3-部门 4-小组',
`parent_id` int DEFAULT 0 COMMENT '上级部门ID',
`sort` int DEFAULT 0 COMMENT '部门排序',
`note` varchar(255) DEFAULT NULL COMMENT '备注',
`create_user` varchar(50) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT (now()) COMMENT '创建时间',
`update_user` varchar(50) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT (now()) COMMENT '更新时间',
`is_delete` int DEFAULT 0 COMMENT '删除标识:0-正常 1-已删除',
PRIMARY KEY (`id`)
) COMMENT = '部门表';
```
## API 接口
| 方法 | 路径 | 权限码 | 说明 |
|------|------|--------|------|
| GET | `/api/dept/list` | sys:dept:page | 树形列表 |
| 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 | 删除 |
## 核心代码
### Logic 配置
```php
// app/logic/DeptLogic.php
class DeptLogic extends BaseTenantLogic
{
/**
* 关联的模型类
*
* @var string
*/
protected string $modelClass = Dept::class;
/**
* LIKE 模糊匹配字段
*
* @var array
*/
protected array $pageLikeFields = ['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 当存在子部门时抛出
*/
protected function beforeDelete(int $id): void
{
$childCount = Dept::where('parent_id', $id)->count();
if ($childCount > 0) {
throw new \Exception('该部门下存在子部门,不可删除');
}
}
}
```
:::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 方法
}
```
@@ -0,0 +1,146 @@
# 6.14 文件模板管理
:::tip 概述
文件模板管理用于维护系统中使用的文件模板,支持按编码查询模板。继承 BaseLogic(系统共享,不隔离)。
:::
## 模块特点
| 特性 | 说明 |
|------|------|
| 基类 | BaseLogic(系统共享,不隔离) |
| 文件字段 | file_path(模板文件),保存到 file_template 目录 |
| 唯一校验 | code 全局唯一 |
| 特殊接口 | getTemplateByCode 按编码查询模板 |
## API 接口
| 方法 | 路径 | 权限码 | 说明 |
|------|------|--------|------|
| GET | `/api/file/template/page` | sys:fileTemplate:page | 分页列表 |
| 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/batchDelete` | sys:fileTemplate:batchDelete | 批量删除 |
| GET | `/api/file/template/getTemplateByCode/:code` | - | 按编码查询模板 |
## 核心代码
```php
// app/logic/FileTemplateLogic.php
class FileTemplateLogic extends BaseLogic
{
/**
* 关联的模型类
*
* @var string
*/
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'];
/**
* 默认排序规则
*
* @var array
*/
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
/**
* 唯一性校验字段
*
* @var array
*/
protected array $uniqueFields = ['code'];
// 按编码查询模板
public function getByCode(string $code): ?array
{
$result = FileTemplate::where('code', $code)->find();
return $result ? $result->toArray() : null;
}
}
```
### Controller 代码
```php
// app/controller/FileTemplateController.php
class FileTemplateController extends BaseController
{
protected FileTemplateLogic $logic;
protected function initialize(): void
{
$this->logic = new FileTemplateLogic();
}
#[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);
}
}
```
:::tip 按编码查询
`getTemplateByCode` 接口未配置权限注解,通常用于前端按编码读取模板配置的场景。
:::
@@ -0,0 +1,211 @@
# 6.15 首页与个人中心
:::tip 概述
首页与个人中心模块负责当前登录用户的信息获取、菜单加载、密码修改和个人信息更新。该模块不继承 BaseLogic,而是专用业务逻辑。
:::
## 模块特点
| 特性 | 说明 |
|------|------|
| 基类 | 无(直接操作 Model) |
| 用途 | 当前登录用户的自身操作 |
| 权限 | 无需配置 #[Permission],已登录即可访问 |
| 超级管理员 | uid=1 拥有全部权限 `*:*:*` |
## API 接口
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/index/getUser` | 获取当前登录用户信息(含角色和权限) |
| GET | `/api/index/getMenus` | 获取当前用户菜单(树形) |
| PUT | `/api/index/updatePassword` | 修改密码 |
| PUT | `/api/index/updateProfile` | 更新个人信息 |
## 核心代码
### 获取用户信息
```php
// app/logic/IndexLogic.php
public function getUser(int $userId): array
{
$user = User::with(['dept'])->find($userId);
if (!$user) {
throw new \Exception('用户不存在');
}
$data = $user->toArray();
// 头像补全域名前缀
if (!empty($data['avatar'])) {
$data['avatar'] = get_file_url($data['avatar']);
}
// 组装城市编码数组(province_code, city_code, district_code, street_code)
$data['city'] = [
$data['province_code'] ?? '',
$data['city_code'] ?? '',
$data['district_code'] ?? '',
$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']);
return $data;
}
```
:::info 返回结构说明
- `roles`:完整角色对象数组(含 id、name、code 等),非仅 ID
- `permissions`:权限码数组,超级管理员为 `['*:*:*']`
- `city`:4 层城市编码数组 `[省, 市, 区, 街道]`
- `avatar`:自动补全域名前缀
- 字段名由 `Result::success()` 自动转为 camelCase 返回前端
:::
### 获取菜单
```php
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();
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);
}
```
:::info 菜单查询条件
获取菜单(非节点)数据时保证四个条件:`type=0`(可导航菜单)、`status=0`(启用)、`hide=0`(未隐藏)、`is_delete=0`(未删除)。
:::
:::info 菜单类型说明
代码中 `where('type', 0)` 用于筛选可导航的菜单项(目录和页面)。在当前实现中,`type=0` 表示可导航的菜单项(包括目录和页面),`type=1` 表示仅用于权限控制的按钮节点,不会出现在菜单树中。
:::
### 修改密码
```php
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('旧密码错误');
}
// 加密新密码
$encrypted = PasswordService::encrypt($newPassword);
$user->password = $encrypted['password'];
$user->salt = $encrypted['salt'];
return $user->save() !== false;
}
```
### 更新个人信息
```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']);
}
// 排除敏感字段
unset($data['password'], $data['salt'], $data['role_id'], $data['tenant_id'], $data['id']);
return $user->save($data) !== false;
}
```
:::warning 安全设计
updateProfile 会排除 `password`、`salt`、`role_id`、`tenant_id` 等敏感字段,防止用户越权修改自身权限。
:::
## 前端调用
```typescript
// 获取当前用户信息
const userInfo = await getUserInfo();
// userInfo.permissions = ['sys:user:page', 'sys:article:add', ...]
// userInfo.roles = [1]
// 获取菜单(用于动态路由生成)
const menus = await getMenuList();
// menus = [{id:1, name:'系统管理', path:'/system', children:[...]}]
// 修改密码
await updatePassword({ oldPassword: '123456', newPassword: '654321' });
// 更新个人信息
await updateProfile({ realname: '张三', mobile: '13800138000' });
```
@@ -0,0 +1,788 @@
# 6.11 定时任务
:::tip 概述
定时任务系统由三部分组成:JobLogic(调度逻辑)+ BaseTask(任务处理器)+ JobRunCommand(命令行入口)。支持三种触发方式:Linux Crontab、Windows 计划任务、Daemon 守护进程。
:::
## 系统架构
```text
┌─────────────────────────────────────────────────────────────────┐
│ 触发层(三选一) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Linux Crontab│ │Windows 计划 │ │ Daemon 守护 │ │
│ │ │ │任务 │ │ 进程 │ │
│ │ * * * * * │ │ schtasks │ │ php think │ │
│ │ php think │ │ │ │ job:daemon │ │
│ │ job:run │ │ │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └─────────────────┼─────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ JobRunCommand │ │
│ │ php think job:run │ │
│ │ 或 php think job:daemon│ │
│ └────────────┬────────────┘ │
└───────────────────────────┼─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 调度层(JobLogic) │
│ │
│ runPending() shouldRun() │
│ ├─ 查询 status=1 的任务 ├─ 解析 cron 表达式 │
│ ├─ 逐个判断是否到执行时间 ├─ 分 时 日 月 周 逐段匹配 │
│ └─ 到期则执行 └─ 全部匹配返回 true │
│ │
│ executeJob($job) │
│ ├─ 检查执行策略(立即/排队/放弃) │
│ ├─ 调用 doExecute() 实际执行 │
│ ├─ 记录 JobLog 执行日志 │
│ └─ 返回执行结果 │
└───────────────────────────┼─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 执行层 │
│ │
│ doExecute($job) │
│ ├─ 判断 URL 类型 │
│ │ │
│ ├─ http:// / https:// → executeHttp() │
│ │ └─ curl 发起 HTTP GET/POST 请求 │
│ │ │
│ └─ 其它 → 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 │
└─────────────────────────────────────────────────────────────────┘
```
## 状态说明
| 状态值 | 说明 | 可执行 | 可暂停 | 可启动 |
|--------|------|--------|--------|--------|
| 0 | 未发布 | ✗ | ✗ | ✓ |
| 1 | 运行中 | ✓ | ✓ | ✗ |
| 2 | 暂停 | ✗ | ✗ | ✓ |
| 3 | 已删除 | ✗ | ✗ | ✗ |
## 执行策略
| 策略值 | 说明 | 行为 |
|--------|------|------|
| 1 | 立即执行 | 丢弃之前的执行,立即运行 |
| 2 | 执行一次 | 等待上一次完成后执行 |
| 3 | 放弃执行 | 如果上一次还在运行则跳过 |
:::tip 枚举文本自动翻译
JobLogic 配置了 `serializeMaps`,API 响应中会自动包含翻译后的文本字段:
| 原始字段 | 文本字段 | 字典编码 | 说明 |
|----------|----------|----------|------|
| `status` | `statusText` | `job_status` | 任务状态(未发布/运行中/暂停/删除) |
| `execute_policy` | `executePolicyText` | `job_execute_policy` | 执行策略(立即执行/执行一次/放弃执行) |
字典数据在代码生成器自动创建,也可在「系统管理 → 字典管理」中手动维护。
:::
## Cron 表达式语法
标准 5 段格式:`分 时 日 月 周`
```text
┌────────── 分钟(0-59)
│ ┌──────── 小时(0-23)
│ │ ┌────── 日(1-31)
│ │ │ ┌──── 月(1-12)
│ │ │ │ ┌── 周(0-6,0=周日)
│ │ │ │ │
* * * * *
```
| 表达式 | 说明 |
|--------|------|
| `* * * * *` | 每分钟 |
| `0 * * * *` | 每小时整点 |
| `0 0 * * *` | 每天 0 点 |
| `0 2 * * *` | 每天凌晨 2 点 |
| `0 0 * * 1` | 每周一 0 点 |
| `0 0 1 * *` | 每月 1 号 0 点 |
| `*/5 * * * *` | 每 5 分钟 |
| `0 */2 * * *` | 每 2 小时 |
| `0 9-18 * * 1-5` | 工作日 9-18 点每小时 |
| `0 2 1,15 * *` | 每月 1 号和 15 号凌晨 2 点 |
---
## 方式一:Linux Crontab(推荐)
:::tip 适用场景
Linux / macOS 服务器,最常用的定时任务触发方式。系统级 cron 每分钟触发一次 PHP 命令。
:::
### 配置步骤
#### 1. 编辑 crontab
```bash
crontab -e
```
#### 2. 添加定时任务
```bash
# 每分钟执行一次(推荐)
* * * * * cd /www/api && php think job:run >> /dev/null 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
```
:::warning 路径注意
- `cd /www/api` 必须切换到项目根目录,否则 ThinkPHP 无法找到配置文件
- `php` 建议使用绝对路径(如 `/usr/bin/php`),避免 cron 环境变量不同导致找不到 PHP
- `>> /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 或本地开发环境,通过任务计划程序定时触发。
:::
### 方式 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
```
### 方式 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
# 参数说明:
# /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` 的完整路径
3. **工作目录**:必须设置为项目根目录
4. **权限**:确保任务以有权限的用户身份运行
5. **日志**:可在参数中添加 `>> log.txt 2>&1` 重定向输出
:::
```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 的场景。进程常驻内存,自行调度。
:::
### 原理
```text
Daemon 进程启动
│
▼
┌─────────────────────┐
│ while (true) │ 无限循环
│ │ │
│ ├─ runPending() │ 扫描并执行到期任务
│ ├─ sleep(60) │ 休眠 60 秒
│ └─ 继续循环 │
└─────────────────────┘
```
### 实现方式
#### 方式 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);
}
}
}
```
Supervisor 配置:
```ini
; /etc/supervisor/conf.d/rxthinkcmf-job.conf
[program:rxthinkcmf-job]
command=php /www/api/think job:daemon
directory=/www/api
autostart=true
autorestart=true
user=www-data
numprocs=1
redirect_stderr=true
stdout_logfile=/var/log/rxthinkcmf-job.log
stdout_logfile_maxbytes=10MB
stdout_logfile_backups=5
```
启动:
```bash
# 重新加载配置
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 服务(推荐)
```ini
; /etc/systemd/system/rxthinkcmf-job.service
[Unit]
Description=RXThinkCMF Job Daemon
After=network.target mysql.service
[Service]
Type=simple
User=www-data
WorkingDirectory=/www/api
ExecStart=/usr/bin/php /www/api/think job:daemon
Restart=always
RestartSec=10
[Install]
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
```
---
## 三种方式对比
| 特性 | Crontab | Windows 计划任务 | Daemon 守护进程 |
|------|---------|-----------------|----------------|
| 适用系统 | Linux / macOS | Windows | 全平台 |
| 最小间隔 | 1 分钟 | 1 分钟 | 1 秒 |
| 资源占用 | 低(按需启动) | 低(按需启动) | 常驻内存 |
| 可靠性 | 高 | 高 | 高(配合 Supervisor) |
| 配置复杂度 | 低 | 中 | 低 |
| 日志追踪 | 需重定向 | 任务计划程序 | 自带输出 |
| 推荐场景 | 生产环境 | Windows 生产 | 需要秒级精度 / 开发调试 |
:::tip 推荐选择
- **Linux 生产环境**:Crontab(简单可靠)
- **Windows 生产环境**:任务计划程序 或 Daemon 守护进程
- **需要秒级精度**:Daemon + Supervisor
- **Docker 环境**:Daemon(容器内运行)
- **本地开发调试**:Daemon 前台运行(直接观察日志输出)
:::
---
## 新建定时任务步骤
### 1. 创建任务处理器
```php
<?php
// app/task/CleanLogTask.php
declare(strict_types=1);
namespace app\task;
/**
* 清理过期日志任务
*
* 数据库 url 字段:CleanLog
* 参数格式:{"days": 30}
*/
class CleanLogTask extends BaseTask
{
/**
* 执行任务
*
* @param array $params 任务参数
* @return string 执行结果消息
* @throws \Exception 执行失败时抛出
*/
public function run(array $params = []): string
{
$days = $params['days'] ?? 30;
$cutoffDate = date('Y-m-d H:i:s', strtotime("-{$days} days"));
// 清理登录日志
$loginCount = \app\model\LoginLog::where('create_time', '<', $cutoffDate)->delete();
// 清理操作日志
$operCount = \app\model\OperationLog::where('create_time', '<', $cutoffDate)->delete();
// 清理任务日志
$jobCount = \app\model\JobLog::where('start_time', '<', $cutoffDate)->delete();
return "已清理 {$days} 天前的日志:登录日志 {$loginCount} 条,操作日志 {$operCount} 条,任务日志 {$jobCount} 条";
}
}
```
### 2. 在数据库 job 表中配置
```sql
INSERT INTO think_job (
job_name, job_alias, job_group, job_trigger,
url, params, cron_expression,
execute_policy, status, is_sync,
is_delete, create_user, create_time
) VALUES (
'清理过期日志', 'CleanLog', '系统维护', 'cron',
'CleanLog', '{"days":30}', '0 2 * * *',
2, 1, 0,
0, 'admin', NOW()
);
```
### 3. 配置触发方式(三选一)
```bash
# 方式一:Linux Crontab
crontab -e
# 添加:* * * * * cd /www/api && php think 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
# 方式三:Daemon 守护进程
php think job:daemon
# 停止:php think job:daemon --stop
```
---
## 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` | - | 守护进程状态 |
| 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/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 | 批量删除 |
---
## 核心代码解析
### Cron 表达式匹配(JobLogic::matchCronField)
```php
// app/logic/JobLogic.php
/**
* 匹配 cron 字段
*
* 支持以下语法:
* - *:匹配所有
* - */n:每隔 n 个单位
* - n-m:范围匹配
* - n,m,...:逗号分隔的多值
* - n:单个值精确匹配
*/
protected function matchCronField(string $cronField, int $current): bool
{
if ($cronField === '*') {
return true;
}
// 处理 */n(每隔 n 分钟/小时等)
if (preg_match('/^\*\/(\d+)$/', $cronField, $m)) {
return $current % (int)$m[1] === 0;
}
// 处理 n-m(范围)
if (preg_match('/^(\d+)-(\d+)$/', $cronField, $m)) {
return $current >= (int)$m[1] && $current <= (int)$m[2];
}
// 处理逗号分隔的多个值
if (strpos($cronField, ',') !== false) {
$values = array_map('intval', explode(',', $cronField));
return in_array($current, $values);
}
// 单个值
return (int)$cronField === $current;
}
```
### 防重复执行(JobLogic::hasRunThisMinute)
```php
/**
* 检查任务在当前分钟是否已执行过
*
* Daemon 模式下,防止同一分钟内重复执行。
*/
public function hasRunThisMinute(int $jobId): bool
{
$minuteStart = date('Y-m-d H:i') . ':00';
$minuteEnd = date('Y-m-d H:i') . ':59';
return JobLog::where('job_id', $jobId)
->where('start_time', '>=', $minuteStart)
->where('start_time', '<=', $minuteEnd)
->count() > 0;
}
```
---
## 常见问题
### Q: 任务不执行?
A: 检查以下几点:
1. 任务状态是否为 `1`(运行中)
2. Cron 表达式格式是否正确(5 段)
3. Crontab 是否已配置且 cron 服务已启动
4. PHP 路径是否正确(建议使用绝对路径)
5. 手动执行 `php think job:run` 是否正常
### Q: 任务重复执行?
A: Daemon 模式下检查 `hasRunThisMinute` 是否生效。Crontab 模式下确保只有一个 cron 在触发。
### Q: 任务执行超时?
A: 检查 PHP 的 `max_execution_time` 配置,长时间任务建议使用 Daemon 模式或设置 `set_time_limit(0)`。
### Q: 如何查看任务执行日志?
A: 两种方式:
1. **控制台实时日志**:Daemon 前台运行时,`[执行]`、`[跳过]`、`[失败]` 等日志直接输出到终端
2. **数据库持久化日志**:通过 API `/api/job/log/page` 查询,或直接查 `think_job_log` 表
### Q: Daemon 控制台没有新日志输出,是卡住了吗?
A: 不是。Daemon 每分钟检查一次,没有到期任务时静默等待,不输出任何日志。这是正常行为,说明当前没有任务需要执行。
### Q: Windows 下 Daemon 如何启动和停止?
A: 在项目根目录打开 CMD:
```cmd
:: 启动
php think job: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 优雅退出
```
启动后,后台管理系统「定时任务」模块顶部会显示绿色「守护进程运行中」按钮(带 PID 提示),表示 Daemon 正常工作。
如提示 `Daemon 已在运行中 (PID: xxx)`,先执行 `php think job:daemon --stop` 停止旧进程,或手动删除 `runtime/job_daemon.lock` 文件。
@@ -0,0 +1,148 @@
# 6.5 职级管理
:::tip 概述
职级管理维护用户的职级属性数据,支持 Excel 导入导出。继承 BaseTenantLogic 实现租户隔离。
:::
## 模块特点
| 特性 | 说明 |
|------|------|
| 基类 | BaseTenantLogic(租户隔离) |
| 唯一校验 | name 全局唯一 |
| 枚举映射 | status → level_status 字典(1=在用 2=停用) |
| 导入导出 | 支持 Excel 导入导出 |
## API 接口
| 方法 | 路径 | 权限码 | 说明 |
|------|------|--------|------|
| GET | `/api/level/page` | sys:level:page | 分页列表 |
| GET | `/api/level/list` | sys:level:list | 全量列表 |
| 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/batchDelete` | sys:level:batchDelete | 批量删除 |
| GET | `/api/level/export` | sys:level:export | 导出 |
| POST | `/api/level/import` | sys:level:import | 导入 |
## 核心代码
### Logic 配置
```php
// app/logic/LevelLogic.php
class LevelLogic extends BaseTenantLogic
{
/**
* 关联的模型类
*
* @var string
*/
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'];
/**
* 枚举显示名映射
*
* @var array
*/
protected array $serializeMaps = [
'status' => 'level_status',
];
}
```
### 导出方法
```php
public function export(array $params = []): string
{
$list = $this->allList($params);
$headers = [
'name' => '职级名称',
'statusText' => '状态', // 枚举显示名
'sort' => '排序',
'create_user' => '创建人',
'create_time' => '创建时间',
];
return ExcelService::export($list, $headers, '职级数据.xlsx');
}
```
### 导入方法
```php
public function import(string $filePath): array
{
$headerMap = [
'职级名称' => 'name',
'状态' => 'status',
'排序' => 'sort',
];
$data = ExcelService::importWithMap($filePath, $headerMap);
// 枚举字段反向映射:文字转数字
foreach ($data as &$row) {
if (!empty($row['status']) && !is_numeric($row['status'])) {
$row['status'] = DictService::getValue('level_status', $row['status']);
}
}
$count = 0;
$errors = [];
foreach ($data as $index => $row) {
try {
$this->add($row);
$count++;
} catch (\Exception $e) {
$errors[] = [
'row' => $index + 2,
'name' => $row['name'] ?? '',
'reason' => $e->getMessage(),
];
}
}
return ['count' => $count, 'errors' => $errors];
}
```
:::tip 导入导出模式
LevelLogic 是导入导出的标准实现示例。导出使用 `allList()` 获取全量数据,导入使用 `ExcelService::importWithMap()` 解析文件,枚举字段通过 `DictService::getValue()` 反向映射。
:::
@@ -0,0 +1,135 @@
# 6.3 菜单管理
:::tip 概述
菜单管理负责维护系统的菜单树和权限节点,是 RBAC 权限体系的基础数据。
:::
## 模块特点
| 特性 | 说明 |
|------|------|
| 基类 | BaseLogic(系统共享,不隔离) |
| 树形结构 | treeParentField='parent_id',支持树形列表 |
| 菜单类型 | type: 0=目录/页面(可导航) 1=权限按钮(仅权限校验,不显示在菜单树) |
## API 接口
| 方法 | 路径 | 权限码 | 说明 |
|------|------|--------|------|
| GET | `/api/menu/list` | sys:menu:page | 树形列表 |
| 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 | 删除 |
## 菜单数据结构
```json
{
"id": 100,
"parentId": 0,
"name": "系统管理",
"type": 0,
"path": "/system",
"icon": "SettingOutlined",
"sort": 10,
"permission": "",
"component": "",
"target": 0,
"hide": false,
"children": [
{
"id": 101,
"parentId": 100,
"name": "用户管理",
"type": 1,
"path": "/user",
"component": "system/user/index",
"permission": "sys:user:page",
"children": [
{
"id": 102,
"parentId": 101,
"name": "添加用户",
"type": 2,
"permission": "sys:user:add"
}
]
}
]
}
```
## 字段说明
| 字段 | 说明 |
|------|------|
| `type` | 0=目录 1=菜单 2=按钮 |
| `path` | 路由路径 |
| `component` | 前端组件路径(type=1 时必填) |
| `permission` | 权限标识(type=1,2 时填写) |
| `target` | 打开方式:0=内部 1=iframe 2=外链 |
| `hide` | 是否隐藏 |
## 核心代码
### Controller
```php
// app/controller/MenuController.php
class MenuController extends BaseController
{
protected MenuLogic $logic;
protected function initialize(): void
{
$this->logic = new MenuLogic();
}
// 树形列表(支持 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);
}
}
```
:::tip 菜单类型
菜单使用 `_list()` 而非 `_index()`,返回全量树形数据。`type` 字段区分:0=目录、1=菜单、2=按钮。前端只渲染 type=0 和 type=1 的节点。
:::
:::warning 删除前校验
删除菜单时,`MenuLogic::beforeDelete` 会检查是否存在子菜单(`think_menu` 中 `parent_id` 等于当前 ID 且未删除的记录),如有子菜单则抛出异常阻止删除。需先删除子菜单再删除父菜单。
:::
@@ -0,0 +1,115 @@
# 6.12 参数管理
:::tip 概述
参数管理维护系统级配置参数,如默认密码、系统名称等。继承 BaseLogic(系统共享,不隔离),通过 ParamService 读取。
:::
## 模块特点
| 特性 | 说明 |
|------|------|
| 基类 | BaseLogic(系统共享,不隔离) |
| 唯一校验 | code 全局唯一 |
| 缓存 | ParamService 两级缓存(请求级 + 持久缓存,1小时过期) |
| 模糊搜索 | name、code |
| 精确匹配 | type、status |
## 数据库表结构
```sql
CREATE TABLE `think_param` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(100) NOT NULL COMMENT '参数名称',
`code` varchar(100) NOT NULL COMMENT '参数编码',
`value` varchar(255) NOT NULL COMMENT '参数值',
`type` int NOT NULL COMMENT '参数类型:0-系统 1-业务',
`status` int NOT NULL COMMENT '参数状态:1-正常 2-禁用',
`sort` int NOT NULL COMMENT '参数排序',
`note` varchar(255) DEFAULT NULL COMMENT '参数备注',
`create_user` varchar(50) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT (now()) COMMENT '创建时间',
`update_user` varchar(50) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT (now()) COMMENT '更新时间',
`is_delete` int DEFAULT 0 COMMENT '删除标识:0-正常 1-已删除',
PRIMARY KEY (`id`)
) COMMENT = '系统参数表';
```
## API 接口
| 方法 | 路径 | 权限码 | 说明 |
|------|------|--------|------|
| GET | `/api/param/page` | sys:param:page | 分页列表 |
| GET | `/api/param/list` | sys:param:list | 全量列表 |
| 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/batchDelete` | sys:param:batchDelete | 批量删除 |
## 核心代码
```php
// app/logic/ParamLogic.php
class ParamLogic extends BaseLogic
{
/**
* 关联的模型类
*
* @var string
*/
protected string $modelClass = Param::class;
/**
* LIKE 模糊匹配字段
*
* @var array
*/
protected array $pageLikeFields = ['name', 'code'];
/**
* 精确匹配字段
*
* @var array
*/
protected array $pageEqFields = ['type', 'status'];
/**
* 默认排序规则
*
* @var array
*/
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
/**
* 唯一性校验字段
*
* @var array
*/
protected array $uniqueFields = ['code'];
}
```
## 使用 ParamService 读取参数
```php
use app\service\ParamService;
// 获取字符串参数
$value = ParamService::getString('DEFAULT_PASSWORD', '123456');
// 获取整数参数
$value = ParamService::getInt('MAX_LOGIN_ATTEMPTS', 5);
// 获取布尔参数
$value = ParamService::getBool('ENABLE_SMS', false);
// 获取默认密码(从系统参数读取,默认 '123456')
$password = ParamService::getDefaultPassword();
```
:::tip 与配置管理的区别
- **参数管理(Param)**:独立的键值对,适合系统级参数(默认密码、限流阈值)
- **配置管理(Config)**:分组 + 配置项,适合网站配置(名称、Logo、SMTP)
:::
@@ -0,0 +1,170 @@
# 6.6 岗位管理
:::tip 概述
岗位管理维护用户的岗位属性数据。继承 BaseTenantLogic 实现租户隔离,是典型的零自定义方法模块——全部能力由属性配置自动装配。
:::
## 模块特点
| 特性 | 说明 |
|------|------|
| 基类 | BaseTenantLogic(租户隔离) |
| 唯一校验 | name 全局唯一 |
| 枚举映射 | status → position_status 字典(1=在用 2=停用) |
| 自定义方法 | 无(全部由基类自动装配) |
## 数据库表结构
```sql
CREATE TABLE `think_position` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(255) NOT NULL COMMENT '岗位名称',
`status` int DEFAULT 0 COMMENT '岗位状态:1-在用 2-停用',
`sort` int DEFAULT 0 COMMENT '岗位排序',
`create_user` varchar(50) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT (now()) COMMENT '创建时间',
`update_user` varchar(50) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT (now()) COMMENT '更新时间',
`is_delete` int DEFAULT 0 COMMENT '删除标识:0-正常 1-已删除',
PRIMARY KEY (`id`)
) COMMENT = '岗位表';
```
## API 接口
| 方法 | 路径 | 权限码 | 说明 |
|------|------|--------|------|
| GET | `/api/position/page` | sys:position:page | 分页列表 |
| GET | `/api/position/list` | sys:position:list | 全量列表 |
| 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/batchDelete` | sys:position:batchDelete | 批量删除 |
## 核心代码
```php
// app/logic/PositionLogic.php
class PositionLogic extends BaseTenantLogic
{
/**
* 关联的模型类
*
* @var string
*/
protected string $modelClass = Position::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'];
/**
* 枚举显示名映射
*
* @var array
*/
protected array $serializeMaps = [
'status' => 'position_status',
];
}
```
### Controller 代码
```php
// app/controller/PositionController.php
class PositionController extends BaseController
{
protected PositionLogic $logic;
protected function initialize(): void
{
$this->logic = new PositionLogic();
}
#[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);
}
}
```
:::tip 极简 CRUD
岗位模块是"零自定义方法"的典型示例——Logic 类只声明属性配置,Controller 调用父类通用方法,无需编写任何业务代码,即可获得完整的增删改查、分页、唯一性校验、枚举翻译能力。
:::
@@ -0,0 +1,183 @@
# 6.2 角色管理
:::tip 概述
角色管理是 RBAC 权限体系的核心,通过角色关联菜单实现权限分配。
:::
## 模块特点
| 特性 | 说明 |
|------|------|
| 基类 | BaseLogic(系统共享,不隔离) |
| 关联 | 与 Menu 多对多(通过 RoleMenu 中间表) |
| 数据权限 | data_scope 字段控制(1=全部 2=本部门 3=仅本人) |
## 套餐化角色预置
系统内置三套租户角色模板,用于按套餐等级快速授权:
| 角色名称 | 定位 | 菜单范围 |
|----------|------|----------|
| 租户-标准版 | 基础功能 | 核心业务模块 |
| 租户-专业版 | 进阶功能 | 核心 + 高级模块 |
| 租户-旗舰版 | 全功能 | 全部菜单(最全) |
创建租户后,通过「角色管理 → 授权菜单」为对应角色分配菜单权限。详见 [租户管理](/zh/modules/tenant.html)。
## API 接口
| 方法 | 路径 | 权限码 | 说明 |
|------|------|--------|------|
| GET | `/api/role/page` | sys:role:page | 分页列表 |
| GET | `/api/role/list` | sys:role:list | 全量列表 |
| 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/batchDelete` | sys:role:batchDelete | 批量删除 |
| GET | `/api/role/menu/list/:id` | - | 获取角色菜单 |
| POST | `/api/role/menu/save` | sys:role:authorize | 保存角色菜单 |
## 菜单权限分配
```php
// app/logic/RoleLogic.php
/**
* 保存角色菜单关联(事务保护)
*
* 处理流程:
* 1. 开启事务
* 2. 先删除该角色原有的全部菜单关联(全量覆盖式保存)
* 3. 遍历新菜单 ID,组装关联记录
* 4. 批量写入关联记录
* 5. 提交事务
*/
public function saveMenus(int $roleId, array $menuIds): bool
{
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;
}
}
```
### Controller 代码
```php
// app/controller/RoleController.php
class RoleController extends BaseController
{
protected RoleLogic $logic;
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());
}
}
}
```
:::warning 权限生效
权限分配后,拥有该角色的用户需重新登录才能获取最新权限。权限变更时相关缓存会自动失效。
:::
@@ -0,0 +1,214 @@
# 6.13 租户管理
:::tip 概述
租户管理是多租户体系的核心,支持租户的 CRUD、创建租户账号、查看租户用户。继承 BaseLogic(系统共享,租户表本身不隔离)。
:::
## 模块特点
| 特性 | 说明 |
|------|------|
| 基类 | BaseLogic(系统共享,租户表不隔离) |
| 特殊接口 | account(创建租户账号)、users(查看租户用户) |
| 删除校验 | beforeDelete 检查用户数 + 保护默认租户 |
| 编码生成 | afterAdd 自动生成租户编码(T+4位ID) |
| 状态校验 | TenantMiddleware 校验租户状态和过期时间 |
## API 接口
| 方法 | 路径 | 权限码 | 说明 |
|------|------|--------|------|
| GET | `/api/tenant/page` | sys:tenant:page | 分页列表 |
| GET | `/api/tenant/list` | sys:tenant:list | 全量列表 |
| 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/batchDelete` | sys:tenant:batchDelete | 批量删除 |
## 核心代码
```php
// app/logic/TenantLogic.php
class TenantLogic extends BaseLogic
{
/**
* 关联的模型类
*
* @var string
*/
protected string $modelClass = Tenant::class;
/**
* LIKE 模糊匹配字段
*
* @var array
*/
protected array $pageLikeFields = ['name', 'code', 'contact'];
/**
* 精确匹配字段
*
* @var array
*/
protected array $pageEqFields = ['status'];
/**
* 默认排序规则
*
* @var array
*/
protected array $pageOrderBy = ['field' => 'id', 'type' => 'desc'];
/**
* 枚举显示名映射
*
* @var array
*/
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;
}
/**
* 新增后处理:自动生成租户编码
*
* @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]);
}
/**
* 删除前处理:保护默认租户 + 检查用户数
*
* @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('该租户下存在用户,不可删除');
}
}
/**
* 列表查询后处理:批量补充租户用户数
*
* @param array &$records 记录列表(引用传递)
* @param array $params 查询参数
* @return void
*/
protected function afterPageList(array &$records, array $params): void
{
if (empty($records)) return;
// 一次性查询当前页所有租户的用户数量,避免 N+1
$tenantIds = array_column($records, 'id');
$counts = User::where('is_delete', 0)
->whereIn('tenant_id', $tenantIds)
->group('tenant_id')
->column('count(*)', 'tenant_id');
foreach ($records as &$record) {
$record['userCount'] = $counts[$record['id']] ?? 0;
}
}
}
```
### 列表返回 userCount
`/api/tenant/page` 和 `/api/tenant/list` 接口返回的每条记录中自动包含 `userCount` 字段,表示该租户下的用户数量。
:::tip 实现方式
通过 `BaseLogic::afterPageList` 钩子实现,使用 `whereIn` + `group` 批量查询,避免 N+1 问题。
:::
### 创建租户账号
```php
// TenantLogic::createAccount()
// 处理流程:
// 1. 校验租户存在 + 用户数限制
// 2. 注入租户ID,调用 UserLogic::add() 完成标准创建
// (含编码自动生成、密码加密、头像处理、角色关联)
```
:::tip 用户编码由 UserLogic 统一生成
创建租户账号时不再由 TenantLogic 直接生成用户编码,
而是通过 UserLogic::beforeAdd 统一生成,确保所有用户创建路径(直接添加、租户账号、导入)编码规则一致。
:::
:::warning 默认租户保护
默认租户(id=1)不可删除,编码不可修改。超级管理员(tenant_id=0)不受租户隔离限制,创建租户隔离数据时自动代入默认租户(tenant_id=1)。详见 [多租户隔离](/zh/core/tenant.html)。
:::
## 套餐化角色预置
系统内置三套租户角色模板,分配不同的菜单权限,用于按套餐等级快速授权:
| 角色名称 | 定位 | 菜单范围 |
|----------|------|----------|
| 租户-标准版 | 基础功能 | 核心业务模块 |
| 租户-专业版 | 进阶功能 | 核心 + 高级模块 |
| 租户-旗舰版 | 全功能 | 全部菜单(最全) |
**使用方式:** 创建租户后,通过「角色管理 → 授权菜单」为对应角色分配菜单权限。创建租户管理员账号时,选择对应套餐角色即可完成授权。
:::tip 设计意图
套餐角色解决了"每个租户都要手动配一遍菜单"的重复工作。管理员创建租户时只需选择套餐,角色权限即刻生效。
:::
## 演示数据
系统内置一组演示数据,可直接登录体验多租户效果:
### 平台级
| 账号 | 密码 | 角色 | 说明 |
|------|------|------|------|
| admin | 123456 | 超级管理员 | 平台管理,可管理所有租户 |
### 租户级
| 账号 | 密码 | 所属租户 | 角色 | 说明 |
|------|------|----------|------|------|
| tenant001 | 123456 | 客户租户A | 租户管理员 | 可体验租户视角的功能与数据隔离 |
:::warning 演示环境注意
以上账号仅用于开发和演示环境,生产环境部署后请立即修改密码或删除演示数据。
:::
## 租户状态
| 字段 | 说明 |
|------|------|
| `status` | 0=禁用 1=启用 |
| `expire_time` | 过期时间,过期后 TenantMiddleware 返回 403 |
| `max_user_num` | 最大用户数限制 |
@@ -0,0 +1,222 @@
# 6.1 用户管理
:::tip 概述
用户管理是系统核心模块,继承 BaseTenantLogic 实现租户隔离,支持 CRUD、重置密码、Excel 导入导出。
:::
## 模块特点
| 特性 | 说明 |
|------|------|
| 租户隔离 | 继承 BaseTenantLogic,自动按 tenant_id 过滤 |
| 数据权限 | 配置 dataScopeDeptField='dept_id',支持按部门过滤 |
| 编码生成 | beforeAdd 自动生成用户编码(租户编码-6位序号,如 T0001-000020) |
| 文件字段 | avatar(头像),保存到 user/avatar 目录 |
| 唯一校验 | username、mobile 全局唯一 |
| 枚举映射 | gender→gender 字典,status→user_status 字典 |
| 密码加密 | bcrypt 双重加盐,新增时自动加密 |
| 角色关联 | 通过 UserRole 中间表关联,支持多角色 |
## 文件清单
| 文件 | 说明 |
|------|------|
| `app/controller/UserController.php` | 控制器(9 个接口) |
| `app/logic/UserLogic.php` | 逻辑层(含 import/export/resetPassword) |
| `app/model/User.php` | 模型 |
| `app/model/UserRole.php` | 用户角色关联模型 |
| `app/validate/UserValidate.php` | 验证器 |
## API 接口
| 方法 | 路径 | 权限码 | 说明 |
|------|------|--------|------|
| GET | `/api/user/page` | sys:user:page | 分页列表 |
| 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/batchDelete` | sys:user:batchDelete | 批量删除 |
| POST | `/api/user/import` | sys:user:import | 导入 |
| GET | `/api/user/export` | sys:user:export | 导出 |
## 特殊逻辑
### 重置密码
```php
// app/controller/UserController.php
public function resetPwd(): Json
{
$data = $this->getJsonBody();
$id = $data['userId'] ?? 0;
if (empty($id)) {
return $this->fail('缺少id参数');
}
$this->logic->resetPassword($id);
return $this->success(null, '密码重置成功');
}
```
**请求参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `userId` | int | 是 | 用户ID |
:::tip 默认密码
重置密码接口无需传递密码参数,系统自动使用 `PasswordService::getDefaultPasswordPlain()` 作为默认密码。
:::
### 新增用户(beforeAdd 钩子)
```php
// app/logic/UserLogic.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);
}
// 处理城市字段
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']);
}
// 密码加密
$password = $data['password'] ?? PasswordService::getDefaultPasswordPlain();
$encrypted = PasswordService::encrypt($password);
$data['password'] = $encrypted['password'];
$data['salt'] = $encrypted['salt'];
return $data;
}
```
:::tip 编码规则
- 用户编码格式:`租户编码-6位序号`,如 `T0001-000020`
- 序号 = 当前租户下未删除用户数 + 1
- 超级管理员(tenantId=0)自动代入默认租户生成编码
- 编码由系统自动生成,前端不传,修改时不可变更
:::
### 新增后保存角色(afterAdd 钩子)
```php
/**
* 新增后处理:保存用户角色关联
*
* @param int $id 新增用户的主键 ID
* @param array $data 新增时提交的原始数据
* @return void
*/
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 ?? '',
]);
}
}
```
:::info 多角色支持
前端传 `roles: [1, 2, 3]` 数组,`afterAdd` 遍历写入 `UserRole` 中间表。`afterUpdate` 同理,先删后插实现全量覆盖。
:::
### 详情补全(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;
// 补充关联名称
$data['levelName'] = Level::getNameMap()[$data['level_id']] ?? '';
$data['positionName'] = Position::getNameMap()[$data['position_id']] ?? '';
$data['deptName'] = Dept::getNameMap()[$data['dept_id']] ?? '';
// 组装城市数组
$data['city'] = [
$data['province_code'] ?? '',
$data['city_code'] ?? '',
$data['district_code'] ?? '',
$data['street_code'] ?? '',
];
// 移除敏感字段
unset($data['password'], $data['salt']);
}
```
### 导出(export)
导出为分批导出(chunkExport),每批 500 条,避免内存溢出。
| 导出列 | 字段来源 |
|--------|----------|
| 用户编码 | code(系统自动生成) |
| 姓名 | realname |
| 性别 | genderText(DictService 自动转换) |
| 手机号 | mobile |
| 邮箱 | email |
| 部门 | deptName(Dept::getNameMap 映射) |
| 职级 | levelName(Level::getNameMap 映射) |
| 岗位 | positionName(Position::getNameMap 映射) |
| 城市 | cityInfo(4个城市编码拼接为名称字符串) |
| 地址 | address |
| 用户名 | username |
| 简介 | intro |
| 状态 | statusText(DictService 自动转换) |
| 备注 | note |
### 导入(import)
导入时需处理以下反向映射:
| 导入列 | 处理逻辑 |
|--------|----------|
| 性别/状态 | 非数字时 DictService::getValue 反查 |
| 部门/职级/岗位 | 名称通过 array_flip(getNameMap) 反查 ID |
| 城市 | cityInfo 通过 City::resolveCityCodes 拆为4个编码 |
| 用户编码 | 忽略(由 beforeAdd 自动生成) |
:::warning 导入不处理 code
导入时即使 Excel 中有「用户编码」列也会被忽略,编码由 `beforeAdd` 自动生成。
:::