签入版本

This commit is contained in:
2026-10-08 17:50:29 +08:00
parent 809750b47e
commit e213a9b31d
444 changed files with 39366 additions and 37533 deletions
@@ -1,46 +1,45 @@
# 4.3 后端分层架构
:::tip 概述
后端采用"三层主干 + 三层支撑"架构:Controller → Logic → Model 构成主干链路,Validate、Service、Middleware 分别提供参数校验、通用能力、请求拦截等支撑。各层职责明确,层间通过方法调用串联,不越层访问。
后端采用"三层主干 + 三层支撑"架构:Controller → Service → Model 构成主干链路,FormRequest、横切服务、Middleware 分别提供参数校验、通用能力、请求拦截等支撑。各层职责明确,层间通过方法调用串联,不越层访问。
:::
## 架构全景
```text
┌─────────────────────────────────────────────────────────────────────┐
│ 支撑层 │
│ 支撑层 │
│ │
│ Validate(参数校验) Service(通用能力) Middleware(请求拦截) │
│ app/validate/*.php app/service/*.php app/middleware/*.php │
│ FormRequest(参数校验) 横切 Service(通用能力) Middleware(请求拦截)│
│ Http/Requests/*.php Services/(Jwt/Dict…) Http/Middleware/ │
└──────────────────────────────┬──────────────────────────────────────┘
│ 被主干层调用
│ 被主干层调用 / 前置拦截
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 主干链路 │
│ 主干链路 │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Controller(控制器层) │ │
│ │ 接收请求 → 获取参数 → 调用 Logic → 封装响应 │ │
│ │ 基类:BaseController | 文件:app/controller/*.php │ │
│ │ 注解标注 → 参数注入 → 调用 Service → 封装响应 │ │
│ │ 基类:Controller | 文件:app/Http/Controllers/Api/*.php │ │
│ └──────────────────────────┬──────────────────────────────────┘ │
│ │ 调用 Logic 方法 │
│ │ 调用 Service 方法 │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Logic(逻辑层) │ │
│ │ 属性配置 → 参数校验 → 生命周期钩子 → 横切处理 → 调用 Model │ │
│ │ 基类:BaseLogic / BaseTenantLogic | 文件:app/logic/*.php │ │
│ │ Service(服务层) │ │
│ │ 属性配置 → 唯一性校验 → 生命周期钩子 → 事务 → 调用 Model │ │
│ │ 基类:BaseService / BaseTenantService | Services/*.php │ │
│ └──────────────────────────┬──────────────────────────────────┘ │
│ │ 调用 Model ORM │
│ │ 调用 Eloquent ORM │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Model(模型层) │ │
│ │ 表映射 → 软删除 → 审计字段 → ORM → SQL │ │
│ │ 基类:BaseModel | 文件:app/model/*.php │ │
│ │ 表映射 → 软删除 → 审计字段 → 关联关系 → ORM → SQL │ │
│ │ Trait:SoftDeletesByFlag / HasAuditColumns | Models/*.php │ │
│ └──────────────────────────┬──────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Database(数据库) │ │
│ │ Database(五种数据库:MySQL / PG / SQL Server / Oracle / SQLite)│
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
@@ -49,117 +48,68 @@
### Controller 层
**文件**:`app/controller/{Module}Controller.php`
**基类**:`BaseController`
**职责**:HTTP 请求入口,只做"搬运"不做"判断"
**文件**:`app/Http/Controllers/Api/{Module}Controller.php`
**基类**:`App\Http\Controllers\Controller`
**职责**:HTTP 请求入口,只做"编排"不做"判断"
| 职责 | 说明 | 示例 |
|------|------|------|
| 参数获取 | 从请求中提取参数 | `$this->getJsonBody()`、`$this->getParams()` |
| 调用 Logic | 委托业务逻辑 | `$this->logic->add($data)` |
| 响应封装 | 统一 JSON 格式返回 | `$this->success()`、`$this->fail()` |
| 注解标注 | 声明权限和日志 | `#[Permission]`、`#[Log]` |
| 参数注入 | 类型注入 FormRequest 完成格式校验 | `add(ExampleRequest $request)` |
| 调用 Service | 委托业务逻辑(快捷方法一行调用) | `$this->_add($service, $data)` |
| 响应封装 | 统一 JSON 格式返回 | `$this->success()` / `$this->fail()` |
| 注解标注 | 声明权限和日志 | `#[Permission]`、`#[OperationLog]` |
**Controller 不应该做:** 业务判断、数据库操作、文件处理、参数校验(特殊场景除外)。
**Controller 不应该做**:业务判断、数据库操作、文件处理、重复的格式校验。
```php
class ArticleController extends BaseController
class ExampleController extends Controller
{
protected ArticleLogic $logic;
public function __construct(protected ExampleService $exampleService) {}
protected function initialize(): void
// 标准 CRUD —— 一行调用(构造注入,无需 initialize)
#[OperationLog('示例管理-查询分页', OperationLog::TYPE_QUERY)]
#[Permission('sys:example:page', '示例分页')]
public function page(): JsonResponse
{
$this->logic = new ArticleLogic();
return $this->_page($this->exampleService);
}
// 标准 CRUD — 一行调用
#[Permission('sys:article:page', '文章分页')]
public function page(): Json
#[OperationLog('示例管理-新增', OperationLog::TYPE_ADD, '新增示例:{name}')]
#[Permission('sys:example:add', '添加示例')]
public function add(ExampleRequest $request): JsonResponse
{
return parent::_index($this->logic);
}
#[Permission('sys:article:add', '添加文章')]
public function add(): Json
{
return parent::_add($this->logic, $this->getJsonBody());
return $this->_add($this->exampleService, $request->validated());
}
}
```
### Logic 层
### Service 层
**文件**:`app/logic/{Module}Logic.php`
**基类**:`BaseLogic`(系统共享)/ `BaseTenantLogic`(租户隔离)
**文件**:`app/Services/{Module}Service.php`
**基类**:`BaseService`(系统共享)/ `BaseTenantService`(租户隔离)
**职责**:业务核心,通过"属性配置 + 生命周期钩子"声明式 CRUD
| 职责 | 方式 | 说明 |
|------|------|------|
| 行为定义 | 属性配置 | `$pageLikeFields`、`$uniqueFields`、`$validateClass` 等 |
| 业务定制 | 生命周期钩子 | `beforeAdd`、`afterUpdate`、`beforeDelete` 等 |
| 自动校验 | `$validateClass` | add/update 时自动调用验证器 |
| 横切处理 | 属性驱动 | 文件字段、富文本、枚举映射、数据权限、租户隔离 |
| 行为定义 | 属性配置 | `$pageLikeFields`、`$uniqueFields`、`$serializeMaps` 等 16 个声明式属性 |
| 业务定制 | 生命周期钩子 | `beforeAdd`、`afterUpdate`、`beforeDelete` 等 10 个钩子 |
| 横切处理 | Trait 组合 | 文件字段、字典翻译、数据权限、租户隔离(6 个 Concerns Trait) |
| 数据安全 | `DB::transaction` | add/update/delete/batchDelete 主流程事务包裹 |
**Logic 不应该做:** 直接 HTTP 交互、直接返回 JSON 响应。
**Service 不应该做**:直接 HTTP 交互、直接返回 JSON 响应。
```php
class ArticleLogic extends BaseTenantLogic
class ExampleService extends BaseService
{
/**
* 关联的模型类
*
* @var string
*/
protected string $modelClass = Article::class;
use HasCrudOperations, HasQueryScopes, HasSerializeMaps, HasUniqueCheck;
/**
* 参数验证器类
*
* @var string
*/
protected string $validateClass = ArticleValidate::class;
protected string $modelClass = Example::class;
protected array $pageLikeFields = ['name'];
protected array $pageEqFields = ['status'];
protected array $uniqueFields = ['name'];
protected array $serializeMaps = ['status' => 'example_status'];
/**
* 需要自动处理的文件上传字段
*
* @var array
*/
protected array $fileFields = ['cover'];
/**
* LIKE 模糊匹配字段
*
* @var array
*/
protected array $pageLikeFields = ['title'];
/**
* 精确匹配字段
*
* @var array
*/
protected array $pageEqFields = ['status', 'category_id'];
/**
* 唯一性校验字段
*
* @var array
*/
protected array $uniqueFields = ['title'];
/**
* 枚举显示名映射
*
* @var array
*/
protected array $serializeMaps = ['status' => 'article_status'];
/**
* 新增前处理:设置默认排序值
*
* @param array $data 待新增的数据
* @return array 处理后的数据
*/
// 业务定制:钩子按需重写
protected function beforeAdd(array $data): array
{
$data['sort'] = $data['sort'] ?? 0;
@@ -170,23 +120,25 @@ class ArticleLogic extends BaseTenantLogic
### Model 层
**文件**:`app/model/{TableName}.php`
**基类**:`BaseModel`
**文件**:`app/Models/{TableName}.php`
**基类**:Eloquent Model + Concerns Trait
**职责**:数据持久化,只做表映射和自动维护
| 职责 | 说明 |
|------|------|
| 表映射 | `protected $name = 'article'` |
| 软删除 | 全局查询范围自动过滤 `is_delete=1` |
| 审计字段 | `onBeforeInsert`/`onBeforeUpdate` 自动填充 |
| 关联关系 | `hasMany`、`belongsTo` 等 |
| 表映射 | `protected $table = 'example'`(不含前缀,`DB_PREFIX` 自动拼接) |
| 软删除 | `SoftDeletesByFlag` 注册全局范围自动过滤 `is_delete=1` |
| 审计字段 | `HasAuditColumns` 的 `creating/updating` 事件自动填充 |
| 关联关系 | `hasMany`、`belongsTo`、`belongsToMany` 等 |
**Model 不应该做:** 业务判断、复杂查询逻辑(交给 Logic)。
**Model 不应该做**:业务判断、复杂查询编排(交给 Service)。
```php
class Article extends BaseModel
class Example extends Model
{
protected $name = 'article';
use HasAuditColumns, SoftDeletesByFlag;
protected $table = 'example';
// 关联关系(按需定义)
public function category()
@@ -196,60 +148,59 @@ class Article extends BaseModel
}
```
### Validate 层
### FormRequest 层(参数校验支撑)
**文件**:`app/validate/{Module}Validate.php`
**基类**:`think\Validate`
**职责**:参数格式校验,定义规则 + 场景
**文件**:`app/Http/Requests/{Module}Request.php`
**基类**:`BaseRequest`
**职责**:参数**格式**校验(必填、类型、长度、枚举),不含业务规则
```php
class ArticleValidate extends Validate
class ExampleRequest extends BaseRequest
{
protected $rule = [
'title' => 'require|max:200',
];
protected $message = [
'title.require' => '文章标题不能为空',
];
protected $scene = [
'add' => ['title'],
'update' => ['title'],
];
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'type' => 'nullable|integer|in:1,2,3,4',
];
}
}
```
**触发方式:** Logic 配置 `$validateClass` 后,add/update 自动调用。
**触发方式**:控制器方法类型注入,方法执行前自动校验;失败由 `failedValidation()` 返回统一 JSON(`code=1` + 字段明细)。
### Service 层
### 横切 Service(通用能力支撑)
**文件**:`app/service/{Name}Service.php`
**文件**:`app/Services/{Name}Service.php`(横切类)
**职责**:跨模块通用能力,无状态、无副作用
| 服务 | 能力 |
|------|------|
| `DictService` | 字典查询(两级缓存) |
| `PasswordService` | 密码加密(bcrypt 双重加盐) |
| `JwtService` | Token 生成/刷新/解析 |
| `ExcelService` | Excel 导入导出 |
| `CaptchaService` | 图片验证码 |
| `JwtService` | 令牌签发/刷新/解析(Redis 有状态) |
| `DictCacheService` | 字典查询(两级缓存) |
| `PasswordService` | 密码加密(bcrypt,成本因子 12) |
| `ExcelService` | Excel 导入导出(表头映射、分批) |
| `CaptchaService` | 图片验证码(IP 限流) |
| `RequestInfoService` | IP 归属地 / OS / 浏览器解析 |
**Service 不应该做:** 持有状态、直接操作数据库(通过 Model 静态方法例外)。
**横切 Service 不应该做**:持有请求状态、直接返回响应。
### Middleware 层
### Middleware 层(请求拦截支撑)
**文件**:`app/middleware/{Name}Middleware.php`
**文件**:`app/Http/Middleware/*.php`
**职责**:请求拦截,在 Controller 前后执行
```text
CorsMiddleware → AuthMiddleware → TenantMiddleware → DemoMiddleware → LogMiddleware → Controller
ProxyMiddleware → Cors → ForceJsonResponse → Authenticate → ResolveTenant → LogOperation → Controller
```
| 中间件 | 作用 | 注入数据 |
|--------|------|----------|
| `AuthMiddleware` | JWT 认证 + 权限校验 | `$request->userInfo` |
| `TenantMiddleware` | 租户上下文 | `$request->tenantId` |
| `DemoMiddleware` | 演示环境拦截 | — |
| `LogMiddleware` | 操作日志记录 | — |
| `Authenticate` | JWT 认证 + 权限校验 | `attributes['userInfo']` |
| `ResolveTenant` | 租户上下文 | `attributes['tenantId']` / `isSuperAdmin` / `tenantInfo` |
| `LogOperation` | 操作日志(handle 采集,terminate 落库) | — |
详见 [4.2 请求处理链路](/zh/architecture/request-flow.html)。
## 各层方法对照表
@@ -257,36 +208,37 @@ CorsMiddleware → AuthMiddleware → TenantMiddleware → DemoMiddleware → Lo
| 层 | 方法 | 输入 | 输出 |
|----|------|------|------|
| Controller | `_index($logic)` | HTTP 请求参数 | Json 响应 |
| Logic | `pageList($params, $where, $with)` | 参数数组 | `['records'=>[], 'total'=>int, ...]` |
| Model | `Model::page($current, $size)->select()` | 分页条件 | Collection |
| Controller | `_page($service)` | HTTP 请求参数 | JsonResponse |
| Service | `pageList($params, $where, $with)` | 参数数组 | `['records'=>[], 'total'=>int, 'current'=>int, 'size'=>int]` |
| Model | `Model::query()->paginate(...)` | 分页条件 | LengthAwarePaginator |
### 新增链路
| 层 | 方法 | 输入 | 输出 |
|----|------|------|------|
| Controller | `_add($logic, $data)` 或 `$this->logic->add($data)` | Json 请求体 | Json 响应 |
| Logic | `add($data)` | 关联数组 | `int`(新记录 ID) |
| Controller | `_add($service, $data)` | FormRequest 校验后的数组 | JsonResponse |
| Service | `add($data)` | snake_case 关联数组 | `int`(新记录 ID) |
| Model | `Model::create($data)` | 关联数组 | Model 实例 |
**Logic::add 内部流程:**
`beforeAdd` → `validateData` → `camel_to_snake` → `processTenantId` → `processFile` → `processContent` → `filterTableFields` → `checkUnique` → `Model::create` → `afterAdd`
**Service::add 内部流程**(HasCrudOperations):
`beforeAdd` → `checkUnique` → `DB::transaction { Model::create(HasAuditColumns 审计填充)}` → `afterAdd`
### 修改链路
| 层 | 方法 | 输入 | 输出 |
|----|------|------|------|
| Controller | `_edit($logic, $id, $data)` | id + Json 请求体 | Json 响应 |
| Logic | `update($id, $data)` | id + 关联数组 | `bool` |
| Model | `Model::find($id)->save($data)` | 模型实例 + 关联数组 | `bool` |
| Controller | `_update($service, $id, $data)` | id + 请求数据 | JsonResponse |
| Service | `update($id, $data)` | id + 关联数组 | `bool` |
| Model | `Model::find($id)->update($data)` | 模型 + 关联数组 | `bool` |
### 删除链路
| 层 | 方法 | 输入 | 输出 |
|----|------|------|------|
| Controller | `_remove($logic, $id)` | id | Json 响应 |
| Logic | `delete($id)` | id | `bool` |
| Model | `$model->save(['is_delete' => 1])` | 软删除标记 | `bool` |
| Controller | `_delete($service, $id)` | id | JsonResponse |
| Service | `delete($id)` | id | `bool` |
| Model | `$model->softDelete()` | 软删除标记 | `bool`(`UPDATE SET is_delete=1`) |
## 数据流向
@@ -296,28 +248,26 @@ CorsMiddleware → AuthMiddleware → TenantMiddleware → DemoMiddleware → Lo
前端请求(camelCase JSON)
│
▼
BaseController::getJsonBody() ← 获取原始参数
FormRequest::validated() ← 格式校验(camelCase 规则),原样传递
│
▼
BaseLogic::beforeAdd($data) ← 钩子:预处理
Controller::_add($service, $data) ← 编排 + 注解元数据
│
▼
BaseLogic::validateData($data) ← 自动参数校验
Service::doAdd 入口 ← 键名统一转为 snake_case
│
▼
BaseLogic::convertQueryParams() ← camelCase → snake_case
Service::beforeAdd($data) ← 钩子:预处理
│
▼
BaseLogic::processFileFieldsOnSave ← 文件字段处理
Service::checkUnique($data) ← 唯一性校验(BizException 拦截)
│
▼
BaseLogic::filterTableFields() ← 过滤非数据库字段
│
▼
BaseLogic::checkUnique() ← 唯一性校验
│
▼
BaseModel::create() ← 审计字段自动填充
DB::transaction {
Service::processTenantId($data) ← 租户填充(HasDataScope 启用时)
Service::processFileFieldsOnSave ← 文件字段处理(HasFileProcessing 启用时)
Model::create($data) ← 审计字段自动填充(creating 事件)
}
│
▼
Database(snake_case)
@@ -329,16 +279,15 @@ Database(snake_case)
Database(snake_case 数据)
│
▼
BaseModel ← ORM 查询
Model ← ORM 查询(软删除全局范围生效)
│
▼
BaseLogic 处理
│ processFileFieldsOnRead() ← 文件字段补全域名
│ processContentFieldsOnRead() ← 富文本占位符替换
│ processSerializeMaps() ← 枚举显示名补全
│ afterDetail() ← 钩子:补充关联数据
Service 处理(processAfterRead 处理链)
│ processFileFieldsOnRead() ← 文件字段补全域名(启用时)
│ processSerializeMapsList() ← 字典显示名补全(status → statusText)
│ afterDetail()/afterPageList() ← 钩子:补充关联数据
▼
Result::convertData() ← snake_case → camelCase
Result::success() / page() ← snake_case → camelCase 转换
│
▼
JSON 响应 → 前端
@@ -350,80 +299,87 @@ JSON 响应 → 前端
Model 层异常(如数据库连接失败)
│
▼ 向上抛出
Logic 层异常(如 beforeDelete 抛出业务异常、validateData 抛出 ValidateException)
│
Service 层异常
│ beforeAdd/beforeDelete 抛出 BizException(业务规则)
│ 唯一性校验抛出 BizException
│ DB::transaction 自动回滚
▼ 向上抛出
Controller 层
│ 方式一:try-catch 捕获,返回 fail()
│ 方式二:不捕获,由 ExceptionHandle 统一处理
│ 方式一:_add/_update/_delete 等快捷方法捕获 BizException → fail(消息, code)
│ 方式二:不捕获,由全局 Handler 统一处理
▼
ExceptionHandle::render()
│ ValidateException → {"code":1, "msg":"参数验证失败", "data":"..."}
│ ModelNotFoundException → {"code":1, "msg":"数据不存在"}
│ RuntimeException(401) → {"code":1, "msg":"未授权"}
│ 其他异常 → {"code":1, "msg":"操作失败"}
Exceptions\Handler::render()
│ BizException → {code:异常code(默认1), msg:"业务提示"}
│ ValidationException → {code:422, msg:"参数验证失败", data:...}
│ AuthenticationException → {code:401}
│ NotFoundHttpException → {code:404}
│ 其它异常 → {code:500, msg:调试原文/生产"操作失败"}
▼
JSON 响应
```
**推荐做法:**
- Controller 通过 `_add`/`_edit`/`_remove` 调用时,不捕获异常,交给 ExceptionHandle
- Controller 直接调用 `$this->logic->add()` 时,try-catch 捕获并返回 `$this->fail()`
- Logic 层的业务异常(如 beforeDelete 校验)直接 `throw new \Exception`
**推荐做法**:
- Controller 通过 `_add` / `_update` / `_delete` 调用时,**不捕获异常**,交给快捷方法与 Handler
- Controller 手写复杂流程时,可 try-catch 捕获 `BizException` 后返回 `$this->fail()`
- Service 层的业务校验直接 `throw new BizException('提示')`,**不要**返回错误码让上层判断
## 完整请求示例
以"新增文章"为例,展示一个请求从进入到返回的完整链路:
以"新增案例"为例,展示一个请求从进入到返回的完整链路:
```text
POST /api/article/add
POST /api/example/add
Content-Type: application/json
Authorization: Bearer <token>
{"title": "新文章", "categoryId": 1, "status": 0}
{"name": "新案例", "type": 1, "status": 1}
```
```text
1. CorsMiddleware → 放行(设置跨域头)
2. AuthMiddleware → 校验 Token → 注入 $request->userInfo
3. TenantMiddleware → 解析租户 → 注入 $request->tenantId
4. LogMiddleware → 记录日志(#[Log] 注解)
1. ProxyMiddleware → 真实 IP 识别(受信代理)
2. Cors → 非 OPTIONS,放行(响应时追加 CORS 头)
3. ForceJsonResponse → Accept 强制 JSON
4. Authenticate → 校验 Token → 注入 attributes['userInfo']
→ 反射 #[Permission] → checkPermission('sys:example:add') ✓
5. ResolveTenant → 注入 tenantId / isSuperAdmin
6. LogOperation(handle) → 收集上下文,读 #[OperationLog]
5. ArticleController::add()
│ getJsonBody() → ["title"=>"新文章", "categoryId"=>1, "status"=>0]
│
7. ExampleRequest
│ rules() 校验 name/type/status ✓
│ validated() → ["name"=>"新案例", "type"=>1, "status"=>1]
▼
6. ArticleLogic::add($data)
│ beforeAdd($data) → 设置默认 sort=0
│ validateData($data, 'add') → 校验 title 必填 ✓
│ camel_to_snake($data) → ["title"=>"新文章", "category_id"=>1, "status"=>0]
│ processTenantId($data) → 加入 tenant_id
│ processFileFieldsOnSave → cover 无值,跳过
│ filterTableFields($data) → 过滤非表字段
│ checkUnique($data) → title 唯一 ✓
│
8. ExampleController::add()
│ 注解元数据已由 4/6 读取
│ $this->_add($service, $data)
▼
7. Article::create($data)
│ onBeforeInsert → 自动填充 create_user, create_time
│
9. ExampleService::add($data)
│ 入口键名转换 → array_camel_to_snake()
│ beforeAdd($data) → 默认 sort(钩子)
│ checkUnique($data) → name 唯一 ✓
│ DB::transaction {
│ Example::create($data) → creating:create_user/create_time 自动填充
│ }
│ afterAdd($id, $data) → 无额外操作
▼
8. Database INSERT
10. Database INSERT → laravel_example → 自增 ID 42
9. ArticleLogic::afterAdd($id, $data) → 无额外操作
11. 返回 {"code":0, "ok":true, "msg":"添加成功", "data":{"id":42}}
10. 返回 {"code":0, "ok":true, "msg":"添加成功", "data":{"id":42}}
12. LogOperation(terminate) → 写入 laravel_operation_log(响应后,不影响业务)
```
## 层间通信规范
| 规则 | 说明 |
|------|------|
| Controller → Logic | ✅ 允许,标准调用 |
| Controller → Service | ✅ 允许,标准调用 |
| Controller → Model | ❌ 禁止,越层 |
| Logic → Model | ✅ 允许,标准调用 |
| Logic → Service | ✅ 允许,调用通用能力 |
| Logic → Controller | ❌ 禁止,反向依赖 |
| Model → Logic | ❌ 禁止,反向依赖 |
| Model → Service | ⚠️ 慎用,仅静态工具方法 |
| Middleware → Controller | ✅ 通过 `$request` 注入数据 |
| Validate → Logic | ❌ 禁止,只做格式校验 |
| Controller → FormRequest | ✅ 允许,类型注入 |
| Service → Model | ✅ 允许,标准调用 |
| Service → 横切 Service | ✅ 允许,调用通用能力(静态/注入均可) |
| Service → Controller | ❌ 禁止,反向依赖 |
| Service → FormRequest | ❌ 禁止,格式校验在 HTTP 入口完成 |
| Model → Service / Controller | ❌ 禁止,反向依赖 |
| Middleware → 后续链路 | ✅ 通过 `request->attributes` 注入数据 |
| FormRequest → 业务规则 | ❌ 禁止,只做格式校验(业务规则在 Service) |
@@ -1,37 +1,39 @@
# 4.6 核心基类体系
:::tip 概述
项目有四个核心基类,构成"控制器 → 逻辑层 → 模型"三层架构的骨架。所有业务模块都必须继承对应的基类,通过属性配置 + 生命周期钩子实现声明式 CRUD,子类只需声明"做什么"而不用写"怎么做"。
项目有四个核心基类/骨架——`Controller`、`BaseService`、`BaseTenantService`、模型 Concerns Trait——构成"控制器 → 服务 → 模型"三层架构的骨架。所有业务模块都必须继承对应的基类,通过属性配置 + 生命周期钩子实现声明式 CRUD,子类只需声明"做什么"而不用写"怎么做"。
:::
## 基类继承关系
:::warning 继承规则
- 系统共享模块(Menu、Dict、Config 等)继承 `BaseLogic`
- 租户隔离模块(User、Role、Article、Dept、Level 等)继承 `BaseTenantLogic`
- 所有模型继承 `BaseModel`,所有控制器继承 `BaseController`
- 系统共享模块(菜单、字典、配置等)继承 `BaseService`
- 租户隔离模块(用户、角色、文章、部门、职级等)继承 `BaseTenantService`
- 所有模型继承 Eloquent `Model` 并组合 `SoftDeletesByFlag` + `HasAuditColumns` Trait
- 所有控制器继承 `App\Http\Controllers\Controller`
- 所有请求类继承 `BaseRequest`
:::
```text
BaseController(控制器基类)
└── 所有 Controller
Controller(控制器基类)
└── 所有 Api/Controller
BaseLogic(逻辑基类 - 系统共享模块)
├── MenuLogic
├── DictLogic
├── ConfigLogic
└── ...
BaseService(服务基类,组合 6 个 Concerns Trait 的占位)
├── ExampleService(系统共享/示例)
├── MenuService、DictCacheService、ConfigService ...
│
└── BaseTenantLogic(租户逻辑基类,继承 BaseLogic)
├── UserLogic
├── RoleLogic
├── ArticleLogic
├── DeptLogic
├── LevelLogic
└── BaseTenantService(租户服务基类,use HasDataScope + tenantScopeField='tenant_id')
├── UserService
├── RoleService
├── ArticleService
└── ...
BaseModel(模型基类,继承 ThinkPHP Model)
└── 所有 Model
Illuminate\Database\Eloquent\Model(Eloquent 模型)
└── 所有模型(use SoftDeletesByFlag, HasAuditColumns)
FormRequest(Laravel 表单请求)
└── BaseRequest(统一失败响应)
└── 所有 Request
```
## 请求处理全链路
@@ -42,21 +44,22 @@ BaseModel(模型基类,继承 ThinkPHP Model)
HTTP 请求
│
▼
BaseController(参数获取、响应封装)
│ getJsonBody() / getParams()
│ success() / fail() / _page()
Controller(编排、响应封装)
│ FormRequest 类型注入 → 格式校验(validated())
│ success() / fail() / _page() / _add()
│
▼
BaseLogic(业务编排、属性驱动)
│ validateData() → 自动参数校验
│ beforeAdd() → 生命周期钩子
│ add() → 文件处理、唯一性校验、写入
│ afterAdd() → 关联表保存
BaseService(业务编排、属性驱动)
│ 入口键名转换 → array_camel_to_snake()
│ beforeAdd() → 生命周期钩子
│ checkUnique() → 唯一性校验(BizException)
│ DB::transaction → 事务包裹
│ processAfterRead → 读取后处理链(文件/字典翻译)
│
▼
BaseModel(数据持久化)
│ onBeforeInsert → 审计字段自动填充
│ soft_delete → 全局过滤已删除数据
Model(数据持久化,Concerns Trait)
│ HasAuditColumns → 审计字段自动填充(creating/updating 事件)
│ SoftDeleteFlagScope → 全局过滤 is_delete=1
│
▼
数据库
@@ -64,286 +67,233 @@ BaseModel(数据持久化)
---
## BaseController
## Controller
**文件**:`app/BaseController.php`
**文件**:`app/Http/Controllers/Controller.php`
### 设计要点
- 通用 CRUD 方法以 `_` 前缀命名,避免与子类路由方法冲突
- 子类只需实现 `initialize()` 并实例化自己的 Logic 层
- 登录用户信息来自 `AuthMiddleware` 注入的 `$request->userInfo`
- 子类通过**构造函数注入** Service(Laravel 依赖注入),无需 `initialize()`
- 当前登录用户通过 `Support\Auth` 工具类获取(`Auth::userId()` 等)
- `BizException` 由 `_add/_update/_delete` 等方法捕获转为失败响应
### 方法分类
```text
┌─────────────────────────────────────────────────────────────┐
│ BaseController │
│ Controller 基类 │
├─────────────────────────────────────────────────────────────┤
│ 统一响应 │
│ success($data, $msg, $raw) 成功响应 │
│ _page($result) 分页响应 │
│ fail($msg, $code, $data) 失败响应 │
│ 统一响应(委托 Support\Result) │
│ success($data, $msg, $raw) 成功响应 │
│ paginate($result) 分页响应 │
│ fail($msg, $code, $data) 失败响应(code 默认 1) │
├─────────────────────────────────────────────────────────────┤
│ 参数获取 │
│ getParam($name, $default) 获取单个参数 │
│ getParams() 获取所有参数 │
│ getPageParams() 获取分页参数(含边界限制) │
│ getJsonBody() 获取 JSON 请求体 │
│ 参数获取 │
│ getPageParams() 分页参数(pageNo≥1, │
│ pageSize 1~100,默认 20) │
│ getJsonBody() JSON 请求体 │
│ getParams() 全部参数(query + body 合并) │
├─────────────────────────────────────────────────────────────┤
│ 登录用户 │
│ getLoginUser() 获取用户信息对象 │
│ getLoginUserId() 获取用户 ID(未登录返回 0) │
│ getLoginUsername() 获取用户名(未登录返回 '') │
├─────────────────────────────────────────────────────────────┤
│ 通用 CRUD(子类通过 parent:: 调用) │
│ _index($logic, $where, $with) 分页查询 │
│ _list($logic, $where) 全量列表 │
│ _treeList($logic, $where) 树形列表 │
│ _detail($logic, $id) 详情查询 │
│ _add($logic, $data) 新增 │
│ _edit($logic, $id, $data) 修改 │
│ _remove($logic, $id) 删除 │
│ _batchRemove($logic, $ids) 批量删除 │
├─────────────────────────────────────────────────────────────┤
│ 手动验证(特殊场景) │
│ validate($data, $validate) 数据验证 │
│ 通用 CRUD(子类通过 $this-> 调用) │
│ _page($service, $where, $with) 分页查询 │
│ _list($service, $where) 全量列表 │
│ _treeList($service, $where) 树形列表 │
│ _detail($service, $id) 详情(空 → 404) │
│ _add($service, $data) 新增(返回 id) │
│ _update($service, $id, $data) 修改 │
│ _delete($service, $id) 删除 │
│ _batchDelete($service, $ids) 批量删除 │
│ _export($service) 导出(返回文件地址) │
│ _import($service) 导入(文件校验 + 结果汇总) │
└─────────────────────────────────────────────────────────────┘
```
### 通用 CRUD 方法与 Logic 层的对应关系
### 通用 CRUD 方法与 Service 层的对应关系
| Controller 方法 | Logic 方法 | 说明 |
| Controller 方法 | Service 方法 | 说明 |
|---|---|---|
| `_index($logic)` | `$logic->pageList()` | 分页查询 |
| `_list($logic)` | `$logic->allList()` | 全量列表 |
| `_treeList($logic)` | `$logic->treeList()` | 树形列表 |
| `_detail($logic, $id)` | `$logic->detail($id)` | 详情查询 |
| `_add($logic, $data)` | `$logic->add($data)` | 新增 |
| `_edit($logic, $id, $data)` | `$logic->update($id, $data)` | 修改 |
| `_remove($logic, $id)` | `$logic->delete($id)` | 删除 |
| `_batchRemove($logic, $ids)` | `$logic->batchDelete($ids)` | 批量删除 |
| `_page($service)` | `pageList()` | 分页查询 |
| `_list($service)` | `allList()` | 全量列表 |
| `_treeList($service)` | `treeList()` | 树形列表 |
| `_detail($service, $id)` | `detail($id)` | 详情查询(空返回 404) |
| `_add($service, $data)` | `add($data)` | 新增(捕获 BizException) |
| `_update($service, $id, $data)` | `update($id, $data)` | 修改(id 缺失直接失败提示) |
| `_delete($service, $id)` | `delete($id)` | 删除(BizException→业务提示;其它异常记日志) |
| `_batchDelete($service, $ids)` | `batchDelete($ids)` | 批量删除(ids 为空直接提示) |
| `_export($service)` | `export()` | 导出 |
| `_import($service)` | `import()` | 导入(扩展名/大小/MIME 三重校验) |
### 分页参数
`getPageParams()` 返回 `['pageNo' => int, 'pageSize' => int]`,带边界限制:
- `pageNo` 最小为 1
- `pageSize` 限制在 1~100,默认 20
- `pageSize` 限制在 1~100,默认 20(防止一次拉取过多数据)
前端传参示例:`GET /api/article/page?pageNo=1&pageSize=10&title=测试&status=1`
前端传参示例:`GET /api/example/page?pageNo=1&pageSize=10&name=测试&status=1`
### 子类实现示例
```php
class ArticleController extends BaseController
class ExampleController extends Controller
{
protected ArticleLogic $logic;
protected function initialize(): void
{
$this->logic = new ArticleLogic();
}
public function __construct(protected ExampleService $exampleService) {}
// 分页查询 — 一行搞定
#[Permission('sys:article:list', '文章列表')]
public function page(): Json
#[Permission('sys:example:page', '示例分页')]
public function page(): JsonResponse
{
return parent::_index($this->logic);
return $this->_page($this->exampleService);
}
// 详情
#[Permission('sys:article:detail', '文章详情')]
public function detail(int $id): Json
#[Permission('sys:example:detail', '示例详情')]
public function detail(int $id): JsonResponse
{
return parent::_detail($this->logic, $id);
return $this->_detail($this->exampleService, $id);
}
// 新增(使用 _add 快捷方法)
#[Log('文章管理-新增', Log::TYPE_ADD, '新增文章:{title}')]
#[Permission('sys:article:add', '添加文章')]
public function add(): Json
// 新增(参数由 ExampleRequest 类型注入校验)
#[OperationLog('示例管理-新增', OperationLog::TYPE_ADD, '新增示例:{name}')]
#[Permission('sys:example:add', '添加示例')]
public function add(ExampleRequest $request): JsonResponse
{
return parent::_add($this->logic, $this->getJsonBody());
return $this->_add($this->exampleService, $request->validated());
}
// 删除 — 一行搞定
#[Permission('sys:article:delete', '删除文章')]
public function delete(int $id): Json
#[Permission('sys:example:delete', '删除示例')]
public function delete(int $id): JsonResponse
{
return parent::_remove($this->logic, $id);
return $this->_delete($this->exampleService, $id);
}
}
```
---
## BaseLogic
## BaseService
**文件**:`app/BaseLogic.php`
**文件**:`app/Services/BaseService.php`
BaseLogic 是整个项目的核心,通过"属性配置 + 生命周期钩子"实现声明式 CRUD。子类只需配置属性和按需重写钩子,即可获得完整的增删改查能力。
BaseService 是整个项目的核心,通过"属性配置 + 生命周期钩子"实现声明式 CRUD。子类只需配置属性和按需重写钩子,即可获得完整的增删改查能力。
### 属性配置清单
### 属性配置清单(全部集中声明于 BaseService)
| 属性 | 类型 | 说明 | 示例 |
|---|---|---|---|
| `modelClass` | string | 关联模型类名(**必须**) | `Article::class` |
| `validateClass` | string | 验证器类名(设置后 add/update 自动校验) | `ArticleValidate::class` |
| `fileFields` | array | 单文件字段列表 | `['cover', 'avatar']` |
| `modelClass` | string | 关联模型类名(**必须**) | `Example::class` |
| `defaultWith` | array | 查询默认预加载关联 | `['creator']` |
| `fileFields` | array | 单文件字段列表(需 use HasFileProcessing) | `['cover', 'avatar']` |
| `multiFileFields` | array | 多文件字段列表(逗号分隔的多个URL) | `['images']` |
| `contentFields` | array | 富文本字段 | `['content']` |
| `fileSaveDir` | string | 文件保存子目录 | `'article'` |
| `pageLikeFields` | array | LIKE 模糊匹配字段 | `['title', 'author']` |
| `pageEqFields` | array | 精确匹配字段 | `['status', 'category_id']` |
| `pageOrderBy` | array | 默认排序规则 | `['field'=>'sort','type'=>'asc']` |
| `uniqueFields` | array | 唯一性校验字段 | `['username', ['code','pid']]` |
| `serializeMaps` | array | 枚举显示名映射 | `['status'=>'article_status']` |
| `contentFields` | array | 富文本字段 | `['content']` |
| `dataScopeUserField` | string | 数据权限:归属字段 | `'create_user'` |
| `dataScopeDeptField` | string | 数据权限:部门字段 | `'dept_id'` |
| `tenantScopeField` | string | 租户隔离字段 | `'tenant_id'` |
| `orderFieldAllow` | array | 允许前端动态排序的字段白名单 | `['create_time']` |
| `uniqueFields` | array | 唯一性校验规则(字符串全局 / 数组分组) | `['username', ['code','pid']]` |
| `serializeMaps` | array | 字典显示名映射 | `['status'=>'article_status']` |
| `dataScopeUserField` | string | 数据权限:归属字段(需 use HasDataScope) | `'create_user'` |
| `dataScopeDeptField` | string | 数据权限:部门字段(需 use HasDataScope) | `'dept_id'` |
| `tenantScopeField` | string | 租户隔离字段(BaseTenantService 预设) | `'tenant_id'` |
| `treeParentField` | string | 树形结构父级字段 | `'parent_id'` |
| `treeLikeField` | string | 树形列表模糊搜索字段 | `'name'` |
### 生命周期钩子
### 生命周期钩子(HasCrudOperations 提供 10 个)
| 钩子 | 时机 | 用途 | 可拦截 |
|---|---|---|---|
| `beforeAdd($data)` | 新增前 | 修改数据、预处理,**返回处理后的 data** | ✅ 抛异常 |
| `afterAdd($id, $data)` | 新增后 | 保存关联表、发送通知 | ❌ |
| `afterAdd($id, $data)` | 新增后 | 保存关联表、发送通知 | ✅(事务内) |
| `beforeUpdate($id, $data)` | 修改前 | 修改数据、预处理,**返回处理后的 data** | ✅ 抛异常 |
| `afterUpdate($id, $data)` | 修改后 | 更新关联表 | ❌ |
| `afterUpdate($id, $data)` | 修改后 | 更新关联表 | ✅(事务内) |
| `beforeDelete($id)` | 删除前 | 检查子级、业务约束 | ✅ 抛异常 |
| `afterDelete($id)` | 删除后 | 清理关联数据 | ❌ |
| `afterDelete($id)` | 删除后 | 清理关联数据 | ✅(事务内) |
| `beforeBatchDelete($ids)` | 批量删除前 | 整体校验 | ✅ 抛异常 |
| `afterBatchDelete($ids)` | 批量删除后 | 批量清理 | ❌ |
| `afterBatchDelete($ids)` | 批量删除后 | 批量清理 | ✅(事务内) |
| `afterDetail($id, &$data)` | 详情查询后 | 补充关联数据、格式化输出(引用传递) | ❌ |
| `afterPageList(&$records, $params)` | 列表查询后 | 批量补充关联数据,分页 pageList 与全量 allList 均触发(引用传递) | ❌ |
### 新增流程(add)
```text
array_camel_to_snake($data) ← 入口键名统一转为 snake_case
│
▼
beforeAdd($data) ← 钩子:可修改数据,抛异常可拦截
│
▼
validateData($data, 'add') ← 自动参数校验(配置了 validateClass 时)
checkUnique($data) ← 唯一性校验(uniqueFields,BizException 拦截)
│
▼
array_camel_to_snake($data) ← 键名 camelCase → snake_case
DB::transaction { ← 事务包裹(钩子抛异常自动回滚)
│
▼
processTenantId($data) ← 自动填充 tenant_id
Model::create($data) ← 写入数据库(HasAuditColumns 自动填充审计字段)
│
▼
processFileFieldsOnSave ← 文件字段:临时文件 → 正式目录
afterAdd($id, $data) ← 钩子:保存关联表等副作用
}
│
▼
processContentFieldsOnSave ← 富文本:迁移媒体文件
│
▼
filterTableFields($data) ← 过滤非数据库字段
│
▼
checkUnique($data) ← 唯一性校验
│
▼
Model::create($data) ← 写入数据库
│
▼
afterAdd($id, $data) ← 钩子:保存关联表等副作用
return $id
```
:::tip 职责划分
参数格式校验**不在 Service 内执行**——由控制器的 FormRequest 在入口完成(见 [3.4 FormRequest 验证器](/zh/guide/validator.html));键名转换由 Service 写入入口(doAdd/doUpdate)统一完成。Service 只做业务规则(唯一性、状态依赖)。
:::
### 修改流程(update)
```text
beforeUpdate($id, $data) ← 钩子
│
▼
validateData($data, 'update', $id) ← 自动参数校验
checkUnique($data, $excludeId) ← 唯一性校验(排除自身)
│
▼
array_camel_to_snake → 文件 → 富文本 → 过滤 → 唯一性(排除自身)
│
▼
Model::find($id)->save($data)
│
▼
afterUpdate($id, $data)
DB::transaction {
Model::find($id)->update($data) ← 更新(HasAuditColumns 强制刷新审计字段)
afterUpdate($id, $data) ← 钩子
}
```
### 删除流程(delete)
```text
beforeDelete($id) ← 钩子:抛异常可拦截
beforeDelete($id) ← 钩子:抛异常可拦截(如存在关联数据)
│
▼
is_delete = 1 ← 软删除
│
▼
afterDelete($id)
DB::transaction {
is_delete = 1 ← 软删除(SoftDeletesByFlag)
afterDelete($id)
}
```
### 查询流程(pageList / allList)
```text
buildQuery($model) ← 构建查询条件(LIKE + 精确匹配 + 数据权限 + 租户隔离)
buildQuery($model) ← 构建查询条件(额外条件 + LIKE + 精确匹配
│ + 数据权限 + 租户隔离,camel→snake 转换参数)
▼
applyOrderBy($model) ← 排序(前端 orderField/orderType 优先,
│ 白名单校验,否则用 pageOrderBy 配置)
▼
$page / select ← 分页 / 全量查询
│
▼
applyOrderBy($model) ← 排序(前端传参优先,否则使用配置)
│
▼
$page / $select ← 分页 / 全量查询
│
▼
processFileFieldsOnReadList ← 文件字段补全域名
│
▼
processSerializeMapsList ← 枚举显示名补全
│
▼
filterFields ← 字段过滤
processAfterRead 处理链
├─ processFileFieldsOnReadList ← 文件字段补全域名(HasFileProcessing 启用时)
├─ processSerializeMapsList ← 字典显示名补全(status → statusText)
└─ filterFields ← 字段过滤(fields 参数指定返回字段)
│
▼
afterPageList(&$records) ← 钩子:批量补充关联数据(引用传递)
```
### 自动参数校验(validateClass)
配置 `$validateClass` 属性后,`add`/`update` 时自动调用对应验证器校验参数,无需在 Controller 中手动调用。
```php
class ArticleLogic extends BaseLogic
{
/**
* 关联的模型类
*
* @var string
*/
protected string $modelClass = Article::class;
/**
* 参数验证器类
*
* @var string
*/
protected string $validateClass = \app\validate\ArticleValidate::class;
// ...
}
```
**工作原理:**
1. `add()` 中:`beforeAdd` → `validateData($data, 'add')` → 后续入库
2. `update()` 中:`beforeUpdate` → `validateData($data, 'update', $id)` → 后续入库
3. 验证器按场景(`add`/`update`)校验,场景不存在时使用默认规则
4. update 场景自动注入 `id` 到 data,确保 `unique` 规则排除当前记录
5. 校验失败抛出 `ValidateException`,由 `ExceptionHandle` 统一返回
**覆盖范围:** 无论是 Controller 通过 `_add`/`_edit` 调用,还是 `import` 等内部调用都会生效。
**向后兼容:** `$validateClass` 默认为空字符串,不设置则不验证。
### 唯一性校验(uniqueFields)
### 唯一性校验(uniqueFields,HasUniqueCheck)
```php
// 全局唯一
@@ -351,211 +301,117 @@ protected array $uniqueFields = ['username', 'mobile'];
// 分组内唯一:code 在同一 pid 下唯一
protected array $uniqueFields = [
'username', // 全局唯一
['code', 'pid'], // code 在同一 pid 下唯一
'username', // 字符串 = 全局唯一
['code', 'pid'], // 数组 = [唯一字段, 分组字段],分组内唯一
];
```
校验时机:add/update 时自动校验(在 `checkUnique()` 中执行)。错误提示格式:"字段中文名 + 已存在",可通过重写 `getFieldLabel()` 自定义。
校验规则(`checkUnique`):
### 查询条件构建
- add/update 时自动执行;值为空时跳过(FormRequest 的 `required` 兜底)
- 更新时自动排除自身(`excludeId`)
- 重复时抛出 `BizException("{字段中文名}已存在")`
- 字段中文名查找顺序:`$fieldLabels` 属性 → Trait 内置映射 → 字段名本身
(子类声明 `protected array $fieldLabels = ['code' => '角色编码'];` 可自定义提示)
`buildQuery()` 按以下顺序叠加条件:
### Trait 占位覆盖机制
1. 额外固定条件(代码层面通过 `$where` 传入)
2. LIKE 模糊匹配(`pageLikeFields` 中配置的字段)
3. 精确匹配(`pageEqFields` 中配置的字段)
4. 数据权限过滤(`applyDataScope`)
5. 租户隔离过滤(`applyTenantScope`)
`BaseService` 底部为 6 个 Trait 的所有方法提供**占位实现**(如 `processFileFieldsOnSave()` 直接 `return $data`)。子类 `use` 对应 Trait 后,PHP Trait 机制自动以真实实现覆盖占位——未启用的 Trait 方法保持空操作,避免调用未定义方法。
### 子类实现示例
| Trait | 提供的方法族 |
|------|-------------|
| `HasCrudOperations` | add / update / delete / batchDelete(事务 + 钩子) |
| `HasQueryScopes` | buildQuery / applyOrderBy / 排序白名单 |
| `HasFileProcessing` | 文件/多文件/富文本字段的保存迁移与读取补全 |
| `HasDataScope` | applyDataScope / applyTenantScope / processTenantId |
| `HasSerializeMaps` | processSerializeMaps(字典翻译) |
| `HasUniqueCheck` | checkUnique / getFieldLabel |
| `HasConfigValueProcessing` | processConfigValueOnSave / buildConfigValueList / formatConfigValueOnRead |
```php
class ArticleLogic extends BaseLogic
{
/**
* 关联的模型类
*
* @var string
*/
protected string $modelClass = Article::class;
/**
* 参数验证器类
*
* @var string
*/
protected string $validateClass = \app\validate\ArticleValidate::class;
/**
* 需要自动处理的文件上传字段
*
* @var array
*/
protected array $fileFields = ['cover'];
/**
* 文件保存的子目录
*
* @var string
*/
protected string $fileSaveDir = 'article';
/**
* 富文本字段
*
* @var array
*/
protected array $contentFields = ['content'];
/**
* LIKE 模糊匹配字段
*
* @var array
*/
protected array $pageLikeFields = ['title'];
/**
* 精确匹配字段
*
* @var array
*/
protected array $pageEqFields = ['status', 'category_id'];
/**
* 默认排序规则
*
* @var array
*/
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
/**
* 唯一性校验字段
*
* @var array
*/
protected array $uniqueFields = ['title'];
/**
* 枚举显示名映射
*
* @var array
*/
protected array $serializeMaps = ['status' => 'article_status'];
/**
* 数据权限:归属字段名(用于按创建人过滤)
*
* @var string
*/
protected string $dataScopeUserField = 'create_user';
/**
* 新增前处理:设置默认排序值
*
* @param array $data 待新增的数据
* @return array 处理后的数据
*/
protected function beforeAdd(array $data): array
{
$data['sort'] = $data['sort'] ?? 0;
return $data;
}
}
```
`HasConfigValueProcessing` 不依赖 `BaseService` 的占位实现,由 `ConfigService` 与 `ConfigItemService` 直接 `use`。
---
## BaseTenantLogic
## BaseTenantService
**文件**:`app/BaseTenantLogic.php`
**文件**:`app/Services/BaseTenantService.php`
### 设计说明
BaseTenantLogic 继承 BaseLogic,**仅做一件事**:将 `tenantScopeField` 设为 `'tenant_id'`,从而让所有子类自动获得租户隔离能力。
BaseTenantService 继承 BaseService,**仅做两件事**:`use HasDataScope` 引入数据权限 + 租户隔离能力,并将 `tenantScopeField` 预设为 `'tenant_id'`,子类自动获得租户隔离:
```php
abstract class BaseTenantLogic extends BaseLogic
abstract class BaseTenantService extends BaseService
{
use Concerns\HasDataScope;
/**
* 租户隔离字段名
* 租户隔离字段
*
* @var string
* 预设为 tenant_id,子类如需使用其它字段名可覆盖。
*/
protected string $tenantScopeField = 'tenant_id';
}
```
### 隔离机制(由 BaseLogic 实现)
### 隔离机制(由 HasDataScope 实现)
| 场景 | 行为 |
|---|---|
| **查询时** | `applyTenantScope` 自动叠加 `WHERE tenant_id = ?` 条件 |
| **新增时** | `processTenantId` 自动填充 `tenant_id` 字段 |
| **超级管理员** | `tenantId = 0` 时不过滤,可跨租户查看数据 |
| **查询时** | `applyTenantScope` 自动叠加 `WHERE tenant_id = ?` |
| **新增时** | `processTenantId` 自动填充租户字段:平台用户代入 `config('tenant.default_tenant_id')`,租户用户代入当前 `tenantId` |
| **超级管理员** | `tenantId = 0`(平台用户)不过滤,可跨租户查看数据 |
### 租户上下文来源
`TenantMiddleware` 解析并注入 `$request->tenantId`。
`ResolveTenant` 中间件解析并注入 `request->attributes['tenantId']`(详见 [4.2 请求处理链路](/zh/architecture/request-flow.html))。
### 使用约定
### 数据权限
- 需要租户隔离的模块继承此基类(如 User、Article、Dept、Level 等)
- 系统共享表不继承此基类,直接继承 BaseLogic(如 Menu、Role、Dict、Config 等)
### 子类示例
`BaseTenantService` **默认未启用数据权限**,子类声明字段后 `HasDataScope` 才会叠加过滤(摘自 `app/Services/UserService.php` 实际代码):
```php
class ArticleLogic extends BaseTenantLogic // 注意:继承 BaseTenantLogic
class UserService extends BaseTenantService
{
/**
* 关联的模型类
*
* @var string
*/
protected string $modelClass = Article::class;
/**
* 参数验证器类
*
* @var string
*/
protected string $validateClass = \app\validate\ArticleValidate::class;
// tenantScopeField 已由基类设为 'tenant_id',无需再声明
// ...
protected string $dataScopeDeptField = 'dept_id'; // 启用按部门过滤
}
```
### 使用约定
- 需要租户隔离的模块继承此类(如 User、Article、Dept、Level 等)
- 系统共享表不继承此类,直接继承 BaseService(如 Menu、Dict、Config 等)
---
## BaseModel
## 模型层(Eloquent Model + Concerns Trait)
**文件**:`app/BaseModel.php`
**文件**:`app/Models/*.php` + `app/Models/Concerns/*.php`
### 设计说明
BaseModel 继承 ThinkPHP 的 Model,提供两项横切能力:
本项目**没有单一模型基类**,模型直接继承 Eloquent `Model`,通过组合两个 Trait 获得横切能力:
1. **软删除**:基于 `is_delete` 字段,不使用 ThinkPHP 内置的 `deleteTime` 机制
2. **审计字段自动写入**:`create_user` / `create_time` / `update_user` / `update_time`
1. **软删除**:`SoftDeletesByFlag`(基于 `is_delete` 字段,不使用 Laravel 内置 `SoftDeletes` 的 `deleted_at` 机制)
2. **审计字段自动写入**:`HasAuditColumns`(`create_user` / `create_time` / `update_user` / `update_time`)
### 软删除机制
```text
┌─────────────────────────────────────────────────────────┐
│ 软删除机制 │
│ 软删除机制 │
├─────────────────────────────────────────────────────────┤
│ │
│ 全局查询范围 soft_delete: │
│ 所有查询自动叠加 WHERE is_delete = 0 │
│ → 已删除数据默认不可见 │
│ 全局查询范围 SoftDeleteFlagScope: │
│ 所有查询自动叠加 WHERE is_delete = 0 │
│ → 已删除数据默认不可见 │
│ │
│ 软删除操作: │
│ $model->softDelete() → UPDATE SET is_delete = 1 │
│ BaseModel::batchSoftDelete($ids) → 批量软删除 │
│ 软删除操作: │
│ $model->softDelete() → UPDATE SET is_delete = 1 │
│ Model::batchSoftDelete($ids) → 批量软删除 │
│ │
│ 查询已删除数据: │
│ Model::withoutGlobalScope(['soft_delete'])->... │
│ 查询已删除数据: │
│ Model::withoutGlobalScope(SoftDeleteFlagScope::class) │
│ │
└─────────────────────────────────────────────────────────┘
```
@@ -565,8 +421,8 @@ BaseModel 继承 ThinkPHP 的 Model,提供两项横切能力:
| 方法 | 类型 | 说明 |
|---|---|---|
| `softDelete()` | 实例方法 | 设置 `is_delete = 1` |
| `batchSoftDelete($ids)` | 静态方法 | 批量软删除,先关闭全局范围再 whereIn 更新 |
| `scopeSoftDelete($query)` | 全局范围 | 自动过滤 `is_delete = 1` 的数据 |
| `batchSoftDelete($ids)` | 静态方法 | 批量软删除(先关闭全局范围再 whereIn 更新) |
| `SoftDeleteFlagScope` | 全局范围 | 查询自动追加 `is_delete = 0` |
### 审计字段自动写入
@@ -574,10 +430,10 @@ BaseModel 继承 ThinkPHP 的 Model,提供两项横切能力:
| 事件 | 自动填充 | 说明 |
|---|---|---|
| `onBeforeInsert`(创建前) | `create_user`、`create_time` | 若调用方已显式传入,则不覆盖 |
| `onBeforeUpdate`(更新前) | `update_user`、`update_time` | `update_time` 始终覆盖 |
| `creating`(创建前) | `create_user`、`create_time` | 调用方已显式传入则不覆盖(`??` 逻辑) |
| `updating`(更新前) | `update_user`、`update_time` | 每次强制覆盖 |
用户来源:`AuthMiddleware` 注入的 `$request->userInfo->username`。若无登录用户(如控制台任务),则跳过用户字段,时间字段仍写入。
用户来源:`Auth::username()`(当前登录用户,来自 `auth.jwt` 中间件注入的 `userInfo`)。无登录用户(如队列/命令行)时用户字段保留空值,时间字段仍写入。
### 数据库字段规范
@@ -596,44 +452,65 @@ BaseModel 继承 ThinkPHP 的 Model,提供两项横切能力:
```php
// 模型定义 — 最简形式
class Article extends BaseModel
class Example extends Model
{
use HasAuditColumns, SoftDeletesByFlag;
/**
* 对应数据库表名(不含前缀)
* 数据表名(不含前缀)
*
* @var string
*/
protected $name = 'article';
protected $table = 'example';
protected $fillable = ['name', 'avatar', 'type', 'status', 'sort'];
}
// 软删除
$article = Article::find(1);
$article->softDelete(); // UPDATE article SET is_delete = 1 WHERE id = 1
$example = Example::find(1);
$example->softDelete(); // UPDATE laravel_example SET is_delete = 1 WHERE id = 1
// 批量软删除
Article::batchSoftDelete([1, 2, 3]);
Example::batchSoftDelete([1, 2, 3]);
// 查询已删除数据(临时关闭全局范围)
$all = Article::withoutGlobalScope(['soft_delete'])->select();
$all = Example::withoutGlobalScope(SoftDeleteFlagScope::class)->get();
// 审计字段自动写入(无需手动处理)
Article::create(['title' => '测试']);
// 自动填充: create_user='admin', create_time='2026-09-22 12:00:00'
Example::create(['name' => '测试']);
// 自动填充: create_user='admin', create_time='2026-10-02 12:00:00'
```
---
## BaseRequest(表单请求基类)
**文件**:`app/Http/Requests/BaseRequest.php`
| 能力 | 说明 |
|---|---|
| `authorize()` | 统一返回 true(鉴权由中间件与 `#[Permission]` 负责) |
| `failedValidation()` | 统一返回 `code=1` + 字段错误明细的 JSON |
详见 [3.4 FormRequest 验证器](/zh/guide/validator.html)。
---
## 基类选择指南
| 场景 | 继承 | 原因 |
|---|---|---|
| 系统级共享数据(菜单、字典、配置) | `BaseLogic` | 不需要租户隔离 |
| 租户业务数据(用户、角色、文章、部门、职级) | `BaseTenantLogic` | 需要按租户隔离数据 |
| 所有数据模型 | `BaseModel` | 获得软删除 + 审计字段 |
| 所有控制器 | `BaseController` | 获得统一响应 + 通用 CRUD |
| 系统级共享数据(菜单、字典、配置) | `BaseService` | 不需要租户隔离 |
| 租户业务数据(用户、角色、文章、部门、职级) | `BaseTenantService` | 自动 `tenant_id` 隔离 |
| 所有数据模型 | `Model` + 双 Trait | 软删除 + 审计字段 |
| 所有控制器 | `Controller` | 统一响应 + 通用 CRUD |
| 所有表单请求 | `BaseRequest` | 统一失败响应 + 键名转换 |
:::tip 快速判断
问自己:这个模块的数据是否需要按租户隔离?
- **是** → 继承 `BaseTenantLogic`
- **否** → 继承 `BaseLogic`
- **是** → 继承 `BaseTenantService`
- **否** → 继承 `BaseService`
文件/富文本/数据权限等能力按需 `use` 对应 Trait,未启用时占位方法自动空操作。
:::
@@ -1,7 +1,7 @@
# 4.8 缓存架构设计
:::tip 概述
项目采用两级缓存架构:请求级缓存(内存)+ 持久缓存(file/Redis),大幅减少数据库查询。
项目采用两级缓存架构:请求级缓存(内存)+ 持久缓存(Cache 门面,默认 Redis),大幅减少数据库查询;数据变更时由 Observer 自动失效对应缓存,保证一致性。
:::
## 两级缓存架构
@@ -19,10 +19,10 @@
│ 未命中
▼
┌─────────────────────────────────────────────────────────┐
│ 第二级:持久缓存(cache() 门面) │
│ 第二级:持久缓存(cache() 门面 / Redis) │
│ │
│ 生命周期:跨请求复用 │
│ 存储位置:file 或 Redis │
│ 存储位置:Redis(CACHE_STORE=redis,必需组件) │
│ 速度:快(毫秒级) │
│ 有效期:1 小时(3600 秒) │
│ │
@@ -41,50 +41,79 @@
| 服务 | 缓存键格式 | 说明 |
|------|-----------|------|
| `DictService` | `dict_{code}` | 字典项映射 |
| `DictService` | `dict_full_{code}` | 字典项完整数据 |
| `ParamService` | `param_{code}` | 系统参数值 |
| `DictCacheService` | `dict_{code}`(前缀 `dict_`) | 字典项映射(`CACHE_PREFIX = 'dict_'`,TTL 见 `CACHE_TTL`) |
| `MenuService` 等 | 权限/菜单缓存 | 角色权限列表(配合 Observer 失效) |
## 缓存一致性:Observer 自动失效
数据变更时由 Eloquent Observer 精确失效相关缓存(注册于 `AppServiceProvider`):
| Observer | 监听模型 | 触发动作 |
|----------|----------|----------|
| `DictItemObserver` | 字典项 | 清除所属字典缓存(`dict_{code}`) |
| `MenuObserver` | 菜单 | 清除菜单/权限相关缓存 |
| `RoleMenuObserver` | 角色-菜单关系 | 清除角色权限缓存 |
```text
字典项变更 → DictItemObserver → DictCacheService::clearCache(code) → 下次读取回源重建
菜单变更 → MenuObserver → 权限缓存失效 → 前端刷新菜单后生效
```
:::tip 设计要点
缓存写入发生在"读取回源"时,失效只做 `forget` 不做主动重建——**删除缓存比更新缓存更安全**,避免并发下旧数据回写。
:::
## 缓存驱动配置
`.env` 配置:
```ini
; 文件缓存(默认)
[CACHE]
DRIVER = file
; Redis 缓存(推荐生产环境)
[CACHE]
DRIVER = redis
REDIS_HOST = 127.0.0.1
REDIS_PORT = 6379
REDIS_PASSWORD =
REDIS_DB = 0
# Redis 缓存(项目默认,必需)
CACHE_STORE=redis
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=null # 设置了 requirepass 时必须同步
```
:::warning 生产环境建议使用 Redis
文件缓存在多进程/多服务器环境下无法共享,生产环境建议配置 Redis 缓存驱动。使用 Redis 前需安装 `php redis` 扩展。
:::warning 不要改回 database/file 驱动
- `CACHE_STORE=database`:数据库脚本**不含** Laravel `cache` 表,会直接报表不存在;
- `file` 驱动:多进程/多服务器环境下无法共享,且本项目 JWT 会话、权限缓存均依赖 Redis;
- 会话 / 缓存 / 队列三者统一使用 Redis(`SESSION_DRIVER` / `CACHE_STORE` / `QUEUE_CONNECTION`)。
:::
## 缓存清除
```php
// 清除指定字典缓存
DictService::clearCache('gender');
// 清除指定字典缓存(Observer 已自动调用,此处为手动兜底)
DictCacheService::clearCache('gender');
// 清除所有字典缓存
DictService::clearAllCache();
// 清除指定参数缓存
ParamService::clearCache('DEFAULT_PASSWORD');
// 清除所有参数缓存
ParamService::clearAllCache();
DictCacheService::clearAllCache();
```
对应接口:
对应接口与命令:
```text
GET /api/dict/refreshCache 刷新字典缓存
```
GET /api/dict/refreshCache 刷新字典缓存(需认证)
php artisan dict:clear-cache 命令行清除字典缓存
php artisan config:clear 清除配置缓存(修改 .env 后)
php artisan cache:clear 清除全部应用缓存
```
## Redis 使用全景
| 用途 | 使用方 | 说明 |
|------|--------|------|
| JWT 双令牌会话 | `JwtService` | refresh_token 存 Redis(支持踢下线、改密全端失效、一次性消费) |
| 字典缓存 | `DictCacheService` | 两级缓存第二级 |
| 权限/菜单缓存 | `Authenticate` 中间件 + Observer | 角色权限列表 |
| 验证码 | `CaptchaService` | 图形验证码存取 + IP 限流 |
| 会话/队列 | Laravel 驱动 | `SESSION_DRIVER` / `QUEUE_CONNECTION` |
连接验证:
```bash
php artisan tinker --execute="try { \Illuminate\Support\Facades\Redis::setex('test:key', 10, 'ok'); echo 'OK: '.\Illuminate\Support\Facades\Redis::get('test:key'); } catch (\Throwable \$e) { echo \$e->getMessage(); }"
# 输出 OK: ok 即连接正常
```
@@ -1,23 +1,27 @@
# 4.7 横切机制
:::tip 概述
横切机制是 BaseLogic/BaseModel 内置的自动处理能力,开发者通过属性配置启用,无需手动编写处理代码。项目共有 9 种横切机制,覆盖数据转换、文件处理、校验、权限、隔离等维度。
横切机制是 `BaseService` / 模型 Trait 内置的自动处理能力,开发者通过属性配置启用,无需手动编写处理代码。项目共有 9 种横切机制,覆盖数据转换、文件处理、校验、权限、隔离等维度;参数格式校验则由 HTTP 入口的 FormRequest 承担。
:::
## 横切机制总览
| 机制 | 配置位置 | 配置属性 | 触发时机 |
|------|---------|---------|---------|
| 驼峰/下划线转换 | `config/api.php` | `camel_snake_convert` | 请求入、响应出 |
| 文件字段处理 | Logic | `fileFields` + `fileSaveDir` | add/update 写入,detail/page 读取 |
| 多文件字段处理 | Logic | `multiFileFields` + `fileSaveDir` | add/update 写入,detail/page 读取 |
| 富文本占位符 | Logic | `contentFields` | add/update 写入,detail/page 读取 |
| 枚举显示名翻译 | Logic | `serializeMaps` | detail/page 查询后 |
| 自动参数校验 | Logic | `validateClass` | add/update 前 |
| 唯一性校验 | Logic | `uniqueFields` | add/update 时 |
| 数据权限过滤 | Logic | `dataScopeUserField` / `dataScopeDeptField` | 查询时 |
| 租户隔离 | Logic(继承 BaseTenantLogic) | `tenantScopeField` | 查询时 + 新增时 |
| 审计字段 + 软删除 | BaseModel | 自动生效 | 所有 CRUD |
| 驼峰/下划线转换 | `config/api.php` | `camel_snake_convert` | 查询参数入、响应出 |
| 文件字段处理 | Service(需 `use HasFileProcessing`) | `fileFields` + `fileSaveDir` | add/update 写入,detail/page 读取 |
| 多文件字段处理 | Service(需 `use HasFileProcessing`) | `multiFileFields` + `fileSaveDir` | add/update 写入,detail/page 读取 |
| 富文本占位符 | Service(需 `use HasFileProcessing`) | `contentFields` | add/update 写入,detail/page 读取 |
| 配置值类型化处理 | Service(需 `use HasConfigValueProcessing`) | 记录的 `type` + `fileSaveDir` | add/update 写入,detail/webIndex 读取 |
| 字典显示名翻译 | Service(需 `use HasSerializeMaps`) | `serializeMaps` | detail/page 查询后 |
| 唯一性校验 | Service(需 `use HasUniqueCheck`) | `uniqueFields` | add/update 时 |
| 数据权限过滤 | Service(需 `use HasDataScope`) | `dataScopeUserField` / `dataScopeDeptField` | 查询时 |
| 租户隔离 | `BaseTenantService`(自动 use HasDataScope) | `tenantScopeField` | 查询时 + 新增时 |
| 审计字段 + 软删除 | 模型 Trait | 自动生效 | 所有 CRUD |
:::tip 参数格式校验在哪里?
不在上表——Laravel 将其前置到 HTTP 入口:控制器方法**类型注入 FormRequest** 即自动校验(见 [3.4 FormRequest 验证器](/zh/guide/validator.html))。Service 只做业务规则(唯一性、状态依赖)。
:::
## 1. 驼峰/下划线自动转换
@@ -29,52 +33,61 @@
请求方向(前端 → 数据库):
前端传参:{ userName: "admin", createTime: "2026-01-01" }
│
▼ BaseLogic::convertQueryParams()
│ array_camel_to_snake($params, $except)
▼ HasQueryScopes::convertQueryParams()
│ camel_to_snake() 逐键转换(排除分页/排序参数)
▼
数据库操作:WHERE user_name = 'admin' AND create_time = '2026-01-01'
表单入参(add/update 写入):
$request->validated() ← 格式校验,原样传递(camelCase 键名)
│
▼ HasCrudOperations::doAdd / doUpdate 入口
│ array_camel_to_snake() 统一转换
▼
入库数据:snake_case 键名(非表字段由 filterTableFields 过滤)
响应方向(数据库 → 前端):
数据库数据:{ user_name: "admin", create_time: "2026-01-01" }
│
▼ Result::convertData()
│ array_snake_to_camel($data)
▼ Result::success() / page()
│ convert_to_camel() / convert_list_to_camel()
▼
前端接收:{ userName: "admin", createTime: "2026-01-01" }
```
### 排除项(不参与转换)
### 排除项(不参与请求方向转换)
以下参数保持 camelCase,不转为 snake_case:
以下参数保持 camelCase,不转为 snake_case(`config('api.convert_except_params')`):
- `pageNo`、`pageSize` — 分页参数
- `orderField`、`orderType` — 排序参数
- `fields` — 字段过滤参数
### 配置开关
```php
// config/api.php
'camel_snake_convert' => env('api.camel_snake_convert', true),
'camel_snake_convert' => env('API_CAMEL_SNAKE_CONVERT', true),
```
```ini
# .env
API.CAMEL_SNAKE_CONVERT = true # 开启(默认)
API.CAMEL_SNAKE_CONVERT = false # 关闭(请求保持驼峰,响应保持下划线)
API_CAMEL_SNAKE_CONVERT=true # 开启(默认)
API_CAMEL_SNAKE_CONVERT=false # 关闭(请求保持驼峰,响应保持下划线)
```
:::tip 开发者无需手动处理
驼峰/下划线转换完全自动,开发者只需在前端用 camelCase,在数据库用 snake_case,框架自动处理双向转换。
驼峰/下划线转换完全自动,开发者只需在前端用 camelCase,在数据库用 snake_case,框架自动处理双向转换。需要原样返回时用 `Result::success($data, $msg, true)`(`$raw=true`)。
:::
## 2. 文件字段自动处理
**配置方式**:Logic 中设置 `fileFields` 和 `fileSaveDir`
**配置方式**:Service 中 `use HasFileProcessing` 并设置 `fileFields` 和 `fileSaveDir`
```php
class ArticleLogic extends BaseTenantLogic
class ArticleService extends BaseTenantService
{
use HasFileProcessing, /* 其它 Trait */;
protected array $fileFields = ['cover', 'attachment'];
protected string $fileSaveDir = 'article';
}
@@ -87,21 +100,21 @@ class ArticleLogic extends BaseTenantLogic
│
▼ processFileFieldsOnSave()
│ 1. 检测字段值是否为临时文件 URL
│ 2. 调用 save_file() 将临时文件迁移到正式目录
│ 2. 将临时文件迁移到正式目录(fileSaveDir)
│ 3. 去掉域名,只存储相对路径
▼
入库存储:cover = "/uploads/article/20260922/abc123.jpg"
入库存储:cover = "article/20261002/abc123.jpg"
```
### 读取时(detail/pageList/allList)
```text
数据库读取:cover = "/uploads/article/20260922/abc123.jpg"
数据库读取:cover = "article/20261002/abc123.jpg"
│
▼ processFileFieldsOnRead()
│ 调用 get_file_url() 补全域名
│ 补全完整访问 URL(config/file.php 的 domain_url)
▼
前端接收:cover = "https://cdn.example.com/uploads/article/20260922/abc123.jpg"
前端接收:cover = "https://cdn.example.com/uploads/article/20261002/abc123.jpg"
```
### 多个单文件字段
@@ -111,18 +124,19 @@ protected array $fileFields = ['cover', 'avatar', 'attachment'];
// 三个字段都会自动处理
```
:::warning 未 use Trait 时为空操作
`BaseService` 中对应方法是**占位实现**(直接 `return $data`)。未 `use HasFileProcessing` 时文件字段不会被处理——这是刻意设计(见 [4.6 核心基类体系](/zh/architecture/base-classes.html)「占位覆盖机制」)。
:::
## 3. 多文件字段自动处理
当一个字段存储多个文件URL(逗号分隔)时,使用 `multiFileFields`:
当一个字段存储多个文件 URL(逗号分隔)时,使用 `multiFileFields`:
**配置方式**:Logic 中设置 `multiFileFields`(复用 `fileSaveDir`)
**配置方式**:Service 中设置 `multiFileFields`(复用 `fileSaveDir`)
```php
class ArticleLogic extends BaseTenantLogic
{
protected array $multiFileFields = ['images', 'attachments'];
protected string $fileSaveDir = 'article';
}
protected array $multiFileFields = ['images', 'attachments'];
protected string $fileSaveDir = 'article';
```
**写入时(add/update)**
@@ -132,34 +146,29 @@ class ArticleLogic extends BaseTenantLogic
│
▼ processMultiFileFieldsOnSave()
│ 1. 按逗号拆分为数组
│ 2. 逐个调用 save_file() 迁移临时文件并去掉域名
│ 2. 逐个迁移临时文件并去掉域名
│ 3. 重新用逗号拼接
▼
入库存储:images = "article/20260922/a.png,article/20260922/b.png"
入库存储:images = "article/20261002/a.png,article/20261002/b.png"
```
**读取时(detail/pageList/allList)**
```text
数据库读取:images = "article/20260922/a.png,article/20260922/b.png"
数据库读取:images = "article/20261002/a.png,article/20261002/b.png"
│
▼ processMultiFileFieldsOnRead()
│ 1. 按逗号拆分为数组
│ 2. 逐个调用 get_file_url() 补全域名
│ 3. 重新用逗号拼接
│ 按逗号拆分 → 逐个补全域名 → 重新拼接
▼
前端接收:images = "http://cdn.example.com/article/20260922/a.png,http://cdn.example.com/article/20260922/b.png"
前端接收:images = "https://cdn.example.com/.../a.png,https://cdn.example.com/.../b.png"
```
## 4. 富文本占位符机制
**配置方式**:Logic 中设置 `contentFields`
**配置方式**:Service 中设置 `contentFields`
```php
class ArticleLogic extends BaseTenantLogic
{
protected array $contentFields = ['content'];
}
protected array $contentFields = ['content'];
```
### 写入时
@@ -178,35 +187,32 @@ class ArticleLogic extends BaseTenantLogic
### 读取时
```text
数据库读取:content = '<p>文章内容</p><img src="[IMG_URL]/uploads/article/img001.jpg">'
数据库读取:content 中含 [IMG_URL] 占位符
│
▼ processContentFieldsOnRead()
│ 将 [IMG_URL] 替换为配置的实际域名
▼
前端接收:content = '<p>文章内容</p><img src="https://cdn.example.com/uploads/article/img001.jpg">'
前端接收:content 中 img src 为完整域名 URL
```
### 好处
域名变更时只需修改 `config/file.php` 中的 `domain_url`,已存储的内容无需逐条更新。
域名变更时只需修改 `.env` 中的 `FILE_DOMAIN_URL`,已存储的内容无需逐条更新。
## 5. 枚举显示名翻译
## 5. 字典显示名翻译
**配置方式**:Logic 中设置 `serializeMaps`
**配置方式**:Service 中 `use HasSerializeMaps` 并设置 `serializeMaps`
```php
class ArticleLogic extends BaseTenantLogic
{
/**
* 枚举显示名映射
*
* @var array
*/
protected array $serializeMaps = [
'status' => 'article_status',
'type' => 'article_type',
];
}
/**
* 字典翻译配置:[字段 => 字典编码]
*
* @var array<string, string>
*/
protected array $serializeMaps = [
'status' => 'article_status',
'type' => 'article_type',
];
```
### 工作原理
@@ -214,84 +220,34 @@ class ArticleLogic extends BaseTenantLogic
```text
数据库查询:{ status: 1, type: 0 }
│
▼ processSerializeMaps()
│ DictService::getText('article_status', 1) → '已发布'
│ DictService::getText('article_type', 0) → '原创'
▼ processSerializeMapsList()
│ DictCacheService 批量取词(两级缓存,同一请求只查一次)
│ getText('article_status', 1) → '已发布'
│ getText('article_type', 0) → '原创'
▼
前端接收:{ status: 1, statusText: '已发布', type: 0, typeText: '原创' }
```
### 字段名映射
当字典编码与字段名不同时,可指定映射:
当字典编码与字段名不同时,直接在映射中指定:
```php
/**
* 枚举显示名映射
*
* @var array
*/
protected array $serializeMaps = [
'gender' => 'gender',
'status' => 'user_status',
];
```
## 6. 自动参数校验
字典数据变更时 `DictItemObserver` 自动清除对应缓存,保证翻译不过期。
**配置方式**:Logic 中设置 `validateClass`
## 6. 唯一性校验
**配置方式**:Service 中 `use HasUniqueCheck` 并设置 `uniqueFields`
```php
class ArticleLogic extends BaseTenantLogic
{
protected string $validateClass = \app\validate\ArticleValidate::class;
}
```
### 工作原理
```text
add() 流程:
beforeAdd($data) → validateData($data, 'add') → 后续入库
update() 流程:
beforeUpdate($id, $data) → validateData($data, 'update', $id) → 后续入库
```
### 验证器示例
```php
class ArticleValidate extends Validate
{
protected $rule = [
'title' => 'require|max:200',
];
protected $message = [
'title.require' => '文章标题不能为空',
];
protected $scene = [
'add' => ['title'],
'update' => ['title'],
];
}
```
校验失败抛出 `ValidateException`,由 `ExceptionHandle` 统一返回 `{"code":1, "msg":"参数验证失败", "data":"文章标题不能为空"}`。
### 覆盖范围
无论是 Controller 通过 `_add`/`_edit` 调用,还是 `import` 等内部调用都会触发校验。
## 7. 唯一性校验
**配置方式**:Logic 中设置 `uniqueFields`
```php
class UserLogic extends BaseTenantLogic
{
// 全局唯一
protected array $uniqueFields = ['username', 'mobile'];
}
// 全局唯一
protected array $uniqueFields = ['username', 'mobile'];
```
### 两种规则
@@ -300,7 +256,7 @@ class UserLogic extends BaseTenantLogic
// 字符串 — 全局唯一
protected array $uniqueFields = ['username', 'mobile'];
// 数组 — 分组内唯一
// 数组 — 分组内唯一:[唯一字段, 分组字段]
protected array $uniqueFields = [
'username', // username 全局唯一
['code', 'pid'], // code 在同一 pid 下唯一
@@ -312,57 +268,45 @@ protected array $uniqueFields = [
```text
add() 时:
checkUnique($data)
→ SELECT * FROM think_user WHERE username = 'admin'
→ 存在 → throw new \Exception('用户名已存在')
→ SELECT * FROM laravel_user WHERE username = 'admin'
→ 存在 → throw new BizException('用户名已存在')
update() 时:
checkUnique($data, $excludeId)
→ SELECT * FROM think_user WHERE username = 'admin' AND id <> 42
→ 存在 → throw new \Exception('用户名已存在')
→ SELECT * FROM laravel_user WHERE username = 'admin' AND id <> 42
→ 存在 → throw new BizException('用户名已存在')
```
- 值为空时跳过(FormRequest 的 `required` 规则兜底)
- `BizException` 中断 `DB::transaction` 事务并回滚,由 Handler 转为 `code=1` 响应
### 错误提示自定义
默认提示格式:"字段中文名 + 已存在"。可通过重写 `getFieldLabel()` 自定义:
默认提示格式:"字段中文名 + 已存在"。中文名查找顺序:`$fieldLabels` 属性 → Trait 内置映射 → 字段名本身:
```php
protected function getFieldLabel(string $field): string
class RoleService extends BaseTenantService
{
$map = [
'username' => '用户名',
'mobile' => '手机号',
'code' => '编码',
// 自定义字段中文名映射
protected array $fieldLabels = [
'code' => '角色编码',
'name' => '角色名称',
];
return $map[$field] ?? $field;
}
```
## 8. 数据权限过滤
## 7. 数据权限过滤
**配置方式**:Logic 中设置 `dataScopeUserField` 和/或 `dataScopeDeptField`
**配置方式**:Service 中设置 `dataScopeUserField` 和/或 `dataScopeDeptField`(需 `use HasDataScope`)
```php
class ArticleLogic extends BaseTenantLogic
{
/**
* 数据权限:归属字段名(用于按创建人过滤)
*
* @var string
*/
protected string $dataScopeUserField = 'create_user';
/**
* 数据权限:部门关联字段名(用于按部门过滤)
*
* @var string
*/
protected string $dataScopeDeptField = 'dept_id';
}
protected string $dataScopeUserField = 'create_user'; // 按创建人过滤
protected string $dataScopeDeptField = 'dept_id'; // 按部门过滤
```
### 三种权限范围
由角色表 `think_role` 的 `data_scope` 字段决定:
数据范围由 `HasDataScope::getRoleDataScope()` 按角色解析。当前 `laravel_role` 表不含 `data_scope` 字段,读取缺省为 1——所有角色按全部数据处理:
```text
┌─────────────────────────────────────────────────────────┐
@@ -376,7 +320,7 @@ class ArticleLogic extends BaseTenantLogic
│ │ A │ │ B │ │███│ │ D │ │ E │ ← 只看到本部门数据 │
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
├─────────────────────────────────────────────────────────┤
│ data_scope = 3 → 仅本人数据 │
│ data_scope = 3 → 仅本人数据(无角色用户默认) │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │ A │ │ B │ │███│ │ D │ │ E │ ← 只看到自己创建的数据 │
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
@@ -387,25 +331,25 @@ class ArticleLogic extends BaseTenantLogic
```text
查询时 applyDataScope():
1. 获取当前用户角色 ID
2. 查询角色的 data_scope
1. 获取当前用户角色 ID(无角色 → 按 3 仅本人)
2. 读取角色数据范围(当前无该字段 → 按 1 全部)
3. scope=1 → 不过滤
4. scope=2 → WHERE dept_id = 当前用户部门ID
5. scope=3 → WHERE create_user = 当前用户名
4. scope=2 → WHERE dept_id = 当前用户部门ID(需声明 dataScopeDeptField)
5. scope=3 → WHERE create_user = 当前用户名(需声明 dataScopeUserField)
```
:::warning 无角色用户
无角色时默认按"仅本人"(scope=3)处理。
:::
## 9. 租户隔离
## 8. 租户隔离
**配置方式**:继承 `BaseTenantLogic`(自动设置 `tenantScopeField = 'tenant_id'`)
**配置方式**:继承 `BaseTenantService`(自动 `use HasDataScope` 并设置 `tenantScopeField = 'tenant_id'`)
```php
class ArticleLogic extends BaseTenantLogic // 继承 BaseTenantLogic 即启用
class ArticleService extends BaseTenantService
{
// tenantScopeField 已由基类设为 'tenant_id',无需再声明
// tenantScopeField 基类默认即为 'tenant_id',无需再声明
}
```
@@ -415,48 +359,48 @@ class ArticleLogic extends BaseTenantLogic // 继承 BaseTenantLogic 即启用
|------|------|---------|
| **查询时** | 自动叠加 `WHERE tenant_id = 当前租户ID` | `applyTenantScope()` |
| **新增时** | 自动填充 `tenant_id` 字段 | `processTenantId()` |
| **超级管理员**(tenantId=0) | 查询不过滤,可跨租户查看 | 条件判断跳过 |
| **超级管理员新增** | 自动代入默认租户(tenant_id=1) | 读取配置 `tenant.default_tenant_id` |
| **平台用户**(tenantId=0) | 查询不过滤,可跨租户查看 | 条件判断跳过 |
| **平台用户新增** | 自动代入默认租户(DEFAULT_TENANT_ID) | 读取 `config/tenant.php` |
### 租户上下文来源
`TenantMiddleware` 解析 JWT 中的租户信息,注入 `$request->tenantId`。
`ResolveTenant` 中间件解析并注入 `request->attributes['tenantId']`,并校验租户状态与有效期(详见 [4.2 请求处理链路](/zh/architecture/request-flow.html))。
### 适用范围
- 需要租户隔离的模块:继承 `BaseTenantLogic`(User、Article、Dept、Level 等)
- 系统共享数据:继承 `BaseLogic`(Menu、Role、Dict、Config 等)
- 需要租户隔离的模块:继承 `BaseTenantService`(User、Article、Dept、Level 等)
- 系统共享数据:继承 `BaseService`(Menu、Dict、Config 等)
## 10. 审计字段 + 软删除(BaseModel)
## 9. 审计字段 + 软删除(模型 Trait)
**配置方式**:继承 `BaseModel` 即自动生效,无需额外配置。
**配置方式**:模型 `use HasAuditColumns, SoftDeletesByFlag` 即自动生效,无需额外配置。
### 软删除
```text
全局查询范围 soft_delete:
全局查询范围 SoftDeleteFlagScope:
所有查询自动叠加 WHERE is_delete = 0
→ 已删除数据默认不可见
软删除操作:
$model->softDelete() → UPDATE SET is_delete = 1
BaseModel::batchSoftDelete($ids) → 批量软删除
Model::batchSoftDelete($ids) → 批量软删除
查询已删除数据:
Model::withoutGlobalScope(['soft_delete'])->...
Model::withoutGlobalScope(SoftDeleteFlagScope::class)->...
```
### 审计字段自动写入
| 事件 | 自动填充字段 | 数据来源 |
|------|------------|---------|
| `onBeforeInsert`(创建前) | `create_user` | `$request->userInfo->username` |
| `onBeforeInsert`(创建前) | `create_time` | `date('Y-m-d H:i:s')` |
| `onBeforeUpdate`(更新前) | `update_user` | `$request->userInfo->username` |
| `onBeforeUpdate`(更新前) | `update_time` | `date('Y-m-d H:i:s')`(始终覆盖) |
| `creating`(创建前) | `create_user` | `Auth::username()`(当前登录用户) |
| `creating`(创建前) | `create_time` | `now()`(已赋值则保留) |
| `updating`(更新前) | `update_user` | `Auth::username()` |
| `updating`(更新前) | `update_time` | `now()`(始终覆盖) |
:::tip 无用户场景
控制台任务、定时任务等无登录用户的场景下,跳过用户字段,时间字段仍写入。
控制台任务、定时任务等无登录用户的场景下,用户字段保留空值(可显式赋值跳过),时间字段仍写入。
:::
### 数据库公共字段
@@ -477,14 +421,14 @@ update_time DATETIME COMMENT '更新时间'
一个模块可以同时启用多种横切机制,它们互不干扰:
```php
class ArticleLogic extends BaseTenantLogic
class ArticleService extends BaseTenantService
{
// 租户隔离(继承自动启用:use HasDataScope + tenantScopeField)
use HasCrudOperations, HasQueryScopes, HasFileProcessing, HasSerializeMaps, HasUniqueCheck;
protected string $modelClass = Article::class;
// 自动参数校验
protected string $validateClass = ArticleValidate::class;
// 文件字段处理
// 文件字段处理(需 HasFileProcessing)
protected array $fileFields = ['cover'];
protected string $fileSaveDir = 'article';
@@ -494,18 +438,16 @@ class ArticleLogic extends BaseTenantLogic
// 富文本占位符
protected array $contentFields = ['content'];
// 枚举显示名翻译
// 字典显示名翻译(需 HasSerializeMaps)
protected array $serializeMaps = ['status' => 'article_status'];
// 唯一性校验
// 唯一性校验(需 HasUniqueCheck)
protected array $uniqueFields = ['title'];
// 数据权限
// 数据权限(需 HasDataScope,租户版基类已 use)
protected string $dataScopeUserField = 'create_user';
// 租户隔离(继承 BaseTenantLogic 自动启用)
// 审计字段 + 软删除(继承 BaseModel 自动生效)
// 审计字段 + 软删除(模型 Trait 自动生效)
}
```
@@ -514,23 +456,20 @@ class ArticleLogic extends BaseTenantLogic
```text
beforeAdd ← 钩子
▼
validateData ← 自动参数校验
(入口) FormRequest ← 参数格式校验(rules)
▼
camel_to_snake ← 驼峰转下划线
checkUnique ← 唯一性校验(BizException 拦截)
▼
processTenantId ← 租户ID填充
▼
processFileFields ← 文件字段处理(单值)
▼
processMultiFileFields ← 多文件字段处理(逗号分隔多值)
▼
processContentFields ← 富文本处理
▼
filterTableFields ← 过滤非数据库字段
▼
checkUnique ← 唯一性校验
▼
Model::create ← 审计字段自动填充
▼
afterAdd ← 钩子
DB::transaction {
processTenantId ← 租户ID填充(HasDataScope 启用时)
processFileFields ← 文件字段处理(单值)
processMultiFileFields ← 多文件字段处理(逗号分隔多值)
processContentFields ← 富文本处理
Model::create ← 审计字段自动填充(creating 事件)
afterAdd ← 钩子
}
```
:::tip 设计价值
开发者只需声明"这个字段是文件"(`fileFields`)、"这个字段要唯一"(`uniqueFields`),全部处理逻辑由基类与 Trait 统一完成——改动一处,全模块受益。
:::
@@ -1,23 +1,24 @@
# 4.9 数据库设计
:::tip 概述
项目支持 MySQL、PostgreSQL、SQL Server、Oracle、SQLite 五种数据库,通过 `.env` 的 `DATABASE.TYPE` 一键切换。所有数据库共用同一套代码,无需修改业务逻辑。本章涵盖命名规范、表结构设计、字段说明、索引策略和多数据库适配。
项目支持 MySQL、PostgreSQL、SQL Server、Oracle、SQLite 五种数据库,通过 `.env` 的 `DB_CONNECTION` 一键切换,`document/` 下提供五套初始化脚本。所有数据库共用同一套代码,无需修改业务逻辑。本章涵盖命名规范、表结构设计、字段说明、索引策略和多数据库适配。
:::
## 命名规范
| 对象 | 规范 | 示例 |
|------|------|------|
| 表名 | `think_` 前缀 + 小写下划线 | `think_user`、`think_article` |
| 表名 | 不含前缀的业务名 + `DB_PREFIX`(默认 `laravel_`) | `laravel_user`、`laravel_article` |
| 模型 `$table` | **不含前缀** | `protected $table = 'user'` |
| 字段名 | 小写下划线 | `create_time`、`user_name` |
| 主键 | 统一 `id`,INT 自增 | `id INT PRIMARY KEY` |
| 外键 | `表名_id` | `user_id`、`role_id` |
| 索引 | `idx_字段名` | `idx_status`、`idx_tenant_id` |
| 唯一索引 | `uk_字段名` | `uk_username` |
| 唯一索引 | `uk_含义`(常含 `is_delete` 协同) | `uk_tenant_username` |
## 公共字段
所有业务表**必须**包含以下公共字段,由 `BaseModel` 自动维护:
所有业务表**必须**包含以下公共字段,由模型 Trait 自动维护:
```sql
-- MySQL 格式
@@ -31,183 +32,183 @@ update_time DATETIME COMMENT '更新时间'
| 字段 | 自动维护方 | 说明 |
|------|-----------|------|
| `is_delete` | BaseModel 软删除机制 | `softDelete()` 设置为 1,全局查询范围自动过滤 |
| `create_user` | BaseModel `onBeforeInsert` | 取当前登录用户名,无用户时留空 |
| `create_time` | BaseModel `onBeforeInsert` | 取当前时间,已传入则不覆盖 |
| `update_user` | BaseModel `onBeforeUpdate` | 取当前登录用户名 |
| `update_time` | BaseModel `onBeforeUpdate` | 始终覆盖为当前时间 |
| `is_delete` | `SoftDeletesByFlag` | `softDelete()` 设置为 1,`SoftDeleteFlagScope` 全局过滤 |
| `create_user` | `HasAuditColumns` creating 事件 | 取 `Auth::username()`,已传入则不覆盖 |
| `create_time` | `HasAuditColumns` creating 事件 | 取 `now()`,已传入则不覆盖 |
| `update_user` | `HasAuditColumns` updating 事件 | 取 `Auth::username()` |
| `update_time` | `HasAuditColumns` updating 事件 | 始终覆盖为当前时间 |
## 核心表清单
## 核心表清单(25 张)
### 用户与权限(6 张表)
### 用户与权限(5 张)
| 表名 | 说明 | Logic 基类 |
| 表名 | 说明 | Service 基类 |
|------|------|-----------|
| `think_user` | 用户表 | BaseTenantLogic |
| `think_user_role` | 用户角色关联表(N:N 中间表) | — |
| `think_role` | 角色表 | BaseLogic |
| `think_role_menu` | 角色菜单关联表(N:N 中间表) | — |
| `think_menu` | 菜单/权限表 | BaseLogic |
| `think_dept` | 部门表(树形) | BaseTenantLogic |
| `laravel_user` | 用户表 | `BaseTenantService` |
| `laravel_user_role` | 用户角色关联表(N:N 中间表) | — |
| `laravel_role` | 角色表 | `BaseTenantService` |
| `laravel_role_menu` | 角色菜单关联表(N:N 中间表) | — |
| `laravel_menu` | 菜单/权限表(菜单即权限) | `BaseService` |
**关联关系:**
```text
think_user ──┐
│ N:N (think_user_role)
think_role ──┤
│ N:N (think_role_menu)
think_menu ──┘
laravel_user ──┐
│ N:N (laravel_user_role)
laravel_role ──┤
│ N:N (laravel_role_menu)
laravel_menu ──┘
think_dept ←── think_user.dept_id
think_tenant ←── think_user.tenant_id
laravel_dept ←── laravel_user.dept_id
laravel_tenant ←── laravel_user.tenant_id
```
### 组织架构(3 张表)
### 组织架构(3 张)
| 表名 | 说明 | Logic 基类 |
| 表名 | 说明 | Service 基类 |
|------|------|-----------|
| `think_dept` | 部门表(树形,parent_id) | BaseTenantLogic |
| `think_position` | 岗位表 | BaseTenantLogic |
| `think_level` | 职级表 | BaseTenantLogic |
| `laravel_dept` | 部门表(树形,parent_id) | `BaseTenantService` |
| `laravel_position` | 岗位表 | `BaseTenantService` |
| `laravel_level` | 职级表 | `BaseTenantService` |
### 租户(1 张表)
### 租户(1 张)
| 表名 | 说明 | Logic 基类 |
| 表名 | 说明 | Service 基类 |
|------|------|-----------|
| `think_tenant` | 租户表 | BaseLogic |
| `laravel_tenant` | 租户表(状态、过期时间、用户数限制) | `BaseService` |
### 系统配置(5 张表)
### 系统配置(5 张)
| 表名 | 说明 | Logic 基类 |
| 表名 | 说明 | Service 基类 |
|------|------|-----------|
| `think_dict` | 字典表 | BaseLogic |
| `think_dict_item` | 字典项表 | BaseLogic |
| `think_config` | 配置组表 | BaseLogic |
| `think_config_item` | 配置项表 | BaseLogic |
| `think_param` | 系统参数表 | BaseLogic |
| `laravel_dict` | 字典表 | `BaseService` |
| `laravel_dict_item` | 字典项表 | `BaseService` |
| `laravel_config` | 配置组表 | `BaseService` |
| `laravel_config_item` | 配置项表 | `BaseService` |
| `laravel_param` | 系统参数表 | `BaseService` |
**关联关系:**
```text
think_dict ←── think_dict_item.dict_id
think_config ←── think_config_item.config_id
laravel_dict ←── laravel_dict_item.dict_id
laravel_config ←── laravel_config_item.config_id
```
### 内容管理(4 张表)
### 内容管理(4 张)
| 表名 | 说明 | Logic 基类 |
| 表名 | 说明 | Service 基类 |
|------|------|-----------|
| `think_article` | 文章表 | BaseTenantLogic |
| `think_category` | 文章分类表(树形) | BaseTenantLogic |
| `think_link` | 友情链接表 | BaseTenantLogic |
| `think_notice` | 通知公告表 | BaseTenantLogic |
| `laravel_article` | 文章表 | `BaseTenantService` |
| `laravel_category` | 文章分类表(树形) | `BaseTenantService` |
| `laravel_link` | 友情链接表 | `BaseTenantService` |
| `laravel_notice` | 通知公告表 | `BaseTenantService` |
### 日志审计(2 张表)
### 日志审计(2 张)
| 表名 | 说明 | Logic 基类 |
| 表名 | 说明 | Service 基类 |
|------|------|-----------|
| `think_login_log` | 登录日志表 | BaseLogic |
| `think_operation_log` | 操作日志表 | BaseLogic |
| `laravel_login_log` | 登录日志表 | `BaseService` |
| `laravel_operation_log` | 操作日志表 | `BaseService` |
### 定时任务(2 张表)
### 定时任务(2 张)
| 表名 | 说明 | Logic 基类 |
| 表名 | 说明 | Service 基类 |
|------|------|-----------|
| `think_job` | 定时任务表 | BaseLogic |
| `think_job_log` | 定时任务执行日志表 | BaseLogic |
| `laravel_job` | 定时任务表 | `BaseService` |
| `laravel_job_log` | 定时任务执行日志表 | `BaseService` |
**关联关系:**
```text
think_job ←── think_job_log.job_id
laravel_job ←── laravel_job_log.job_id
```
### 基础数据(2 张表)
### 基础数据(3 张)
| 表名 | 说明 | Logic 基类 |
| 表名 | 说明 | Service 基类 |
|------|------|-----------|
| `think_city` | 城市行政区划表(树形,pid) | BaseLogic |
| `think_file_template` | 文件模板表 | BaseLogic |
| `laravel_city` | 城市行政区划表(树形) | `BaseService` |
| `laravel_file_template` | 文件模板表 | `BaseService` |
| `laravel_example` | 案例演示表(生成器参考模板) | `BaseService` |
**共计 25 张业务表**(不含中间表)。
:::tip 租户隔离范围
继承 `BaseTenantService` 的 9 个服务:User / Role / Dept / Position / Level / Article / Category / Notice / Link——业务数据按租户隔离;系统共享数据(字典、配置、菜单、日志等)继承 `BaseService` 不隔离。
:::
## 关键表字段说明
### think_user(用户表)
### laravel_user(用户表)
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | INT | 主键 |
| `username` | VARCHAR(50) | 用户名(登录账号) |
| `tenant_id` | INT | 租户 ID(0=平台用户) |
| `code` | VARCHAR(100) | 用户编码 |
| `username` | VARCHAR(30) | 用户名(登录账号) |
| `password` | VARCHAR(255) | 密码(bcrypt 加密) |
| `salt` | VARCHAR(50) | 密码盐值 |
| `realname` | VARCHAR(50) | 姓名 |
| `gender` | TINYINT | 性别(字典 gender) |
| `mobile` | VARCHAR(20) | 手机号 |
| `email` | VARCHAR(100) | 邮箱 |
| `avatar` | VARCHAR(500) | 头像路径 |
| `status` | TINYINT | 状态(字典 user_status) |
| `dept_id` | INT | 部门 ID |
| `level_id` | INT | 职级 ID |
| `position_id` | INT | 岗位 ID |
| `tenant_id` | INT | 租户 ID |
| `province_code` | VARCHAR(20) | 省编码 |
| `city_code` | VARCHAR(20) | 市编码 |
| `district_code` | VARCHAR(20) | 区编码 |
| `street_code` | VARCHAR(20) | 街道编码 |
| `city_info` | VARCHAR(200) | 城市名称拼接 |
| `is_delete` | TINYINT(1) | 软删除 |
| `create_user` | VARCHAR(50) | 创建人 |
| `create_time` | DATETIME | 创建时间 |
| `update_user` | VARCHAR(50) | 更新人 |
| `update_time` | DATETIME | 更新时间 |
| `salt` | VARCHAR(30) | 加密盐(外部盐,与 password 分离存储) |
| `realname` | VARCHAR(150) | 用户姓名 |
| `gender` | INT | 性别:1-男 2-女 3-保密 |
| `mobile` / `email` | VARCHAR(30) | 手机号 / 邮箱 |
| `avatar` | VARCHAR(255) | 用户头像 |
| `status` | INT | 状态:1-正常 2-禁用 |
| `dept_id` / `level_id` / `position_id` | INT | 部门 / 职级 / 岗位 ID |
| `province_code` / `city_code` / `district_code` / `street_code` | VARCHAR(30) | 省市县街道编码 |
| `city_info` | VARCHAR(255) | 省市区信息拼接 |
| `address` | VARCHAR(255) | 详细地址 |
| `intro` / `note` | VARCHAR(255) | 个人简介 / 备注 |
| `sort` | INT | 排序 |
| `is_delete` + 四个审计字段 | | 公共字段 |
### think_role(角色表)
**唯一索引(与软删除协同):**
```sql
UNIQUE INDEX uk_tenant_username (tenant_id, username, is_delete)
UNIQUE INDEX uk_tenant_mobile (tenant_id, mobile, is_delete)
UNIQUE INDEX uk_tenant_code (tenant_id, code, is_delete)
```
:::tip 为什么唯一索引含 is_delete?
软删除的用户 `is_delete=1` 仍占唯一索引——把 `is_delete` 纳入联合唯一后,同名账号删除后可重新注册(新记录 `is_delete=0` 不与已删记录冲突),同时保证**同租户内未删数据**用户名唯一。业务层的 `uniqueFields` 校验与数据库索引双保险。
:::
### laravel_role(角色表)
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | INT | 主键 |
| `name` | VARCHAR(50) | 角色名称 |
| `code` | VARCHAR(50) | 角色编码 |
| `data_scope` | TINYINT | 数据权限范围:1=全部 2=本部门 3=仅本人 |
| `tenant_id` | INT | 租户 ID(角色按租户隔离) |
| `status` | TINYINT | 状态 |
| `remark` | VARCHAR(500) | 备注 |
| `sort` | INT | 排序 |
| `...` | | 公共字段 |
| `remark` / `sort` | | 备注 / 排序 |
### think_menu(菜单/权限表)
### laravel_menu(菜单/权限表)
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | INT | 主键 |
| `parent_id` | INT | 父级 ID(0=顶级) |
| `name` | VARCHAR(50) | 菜单名称 |
| `permission` | VARCHAR(100) | 权限标识(如 `sys:user:add`) |
| `type` | TINYINT | 类型:0=目录 1=菜单 2=按钮 |
| `permission` | VARCHAR(100) | 权限标识(如 `sys:user:add`,**菜单即权限**) |
| `type` | TINYINT | 类型:0=菜单/目录(可导航) 1=权限节点 |
| `path` | VARCHAR(200) | 路由路径 |
| `icon` | VARCHAR(100) | 图标 |
| `sort` | INT | 排序 |
| `status` | TINYINT | 状态 |
| `remark` | VARCHAR(500) | 备注 |
| `...` | | 公共字段 |
| `sort` / `status` / `remark` | | 排序 / 状态 / 备注 |
### think_tenant(租户表)
### laravel_tenant(租户表)
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | INT | 主键 |
| `name` | VARCHAR(100) | 租户名称 |
| `code` | VARCHAR(50) | 租户编码(系统自动生成:T+4位ID) |
| `code` | VARCHAR(50) | 租户编码(系统自动生成) |
| `status` | TINYINT | 状态(字典 tenant_status):0=禁用 1=启用 |
| `expire_time` | DATETIME | 过期时间 |
| `expire_time` | DATETIME | 过期时间(ResolveTenant 中间件校验) |
| `max_user_num` | INT | 最大用户数限制 |
| `contact_name` | VARCHAR(50) | 联系人 |
| `contact_phone` | VARCHAR(20) | 联系电话 |
| `remark` | VARCHAR(500) | 备注 |
| `...` | | 公共字段 |
| `contact_name` / `contact_phone` | | 联系人 / 联系电话 |
### think_article(文章表)
### laravel_article(文章表)
| 字段 | 类型 | 说明 |
|------|------|------|
@@ -215,51 +216,48 @@ think_job ←── think_job_log.job_id
| `title` | VARCHAR(200) | 标题 |
| `category_id` | INT | 分类 ID |
| `cover` | VARCHAR(500) | 封面图路径 |
| `content` | TEXT | 内容(富文本,含 [IMG_URL] 占位符) |
| `content` | TEXT | 内容(富文本,含 `[IMG_URL]` 占位符) |
| `author` | VARCHAR(50) | 作者 |
| `status` | TINYINT | 状态(字典 article_status):0=正常 1=下架 |
| `status` | TINYINT | 状态(字典 article_status) |
| `sort` | INT | 排序 |
| `tenant_id` | INT | 租户 ID |
| `...` | | 公共字段 |
## 索引策略
### 推荐索引
```sql
-- 用户表:高频查询字段
ALTER TABLE think_user ADD INDEX idx_username (username);
ALTER TABLE think_user ADD INDEX idx_tenant_id (tenant_id);
ALTER TABLE think_user ADD INDEX idx_dept_id (dept_id);
ALTER TABLE think_user ADD INDEX idx_status (status);
-- 用户表:高频查询字段(联合唯一索引见上文)
ALTER TABLE laravel_user ADD INDEX idx_tenant_id (tenant_id);
ALTER TABLE laravel_user ADD INDEX idx_dept_id (dept_id);
ALTER TABLE laravel_user ADD INDEX idx_status (status);
-- 角色关联表:外键索引
ALTER TABLE think_user_role ADD INDEX idx_user_id (user_id);
ALTER TABLE think_user_role ADD INDEX idx_role_id (role_id);
ALTER TABLE think_role_menu ADD INDEX idx_role_id (role_id);
ALTER TABLE think_role_menu ADD INDEX idx_menu_id (menu_id);
ALTER TABLE laravel_user_role ADD INDEX idx_user_id (user_id);
ALTER TABLE laravel_user_role ADD INDEX idx_role_id (role_id);
ALTER TABLE laravel_role_menu ADD INDEX idx_role_id (role_id);
ALTER TABLE laravel_role_menu ADD INDEX idx_menu_id (menu_id);
-- 文章表:常用过滤字段
ALTER TABLE think_article ADD INDEX idx_category_id (category_id);
ALTER TABLE think_article ADD INDEX idx_status (status);
ALTER TABLE think_article ADD INDEX idx_tenant_id (tenant_id);
ALTER TABLE laravel_article ADD INDEX idx_category_id (category_id);
ALTER TABLE laravel_article ADD INDEX idx_status (status);
ALTER TABLE laravel_article ADD INDEX idx_tenant_id (tenant_id);
-- 日志表:按时间查询
ALTER TABLE think_login_log ADD INDEX idx_create_time (create_time);
ALTER TABLE think_operation_log ADD INDEX idx_create_time (create_time);
ALTER TABLE think_job_log ADD INDEX idx_job_id (job_id);
ALTER TABLE laravel_login_log ADD INDEX idx_create_time (create_time);
ALTER TABLE laravel_operation_log ADD INDEX idx_create_time (create_time);
ALTER TABLE laravel_job_log ADD INDEX idx_job_id (job_id);
-- 菜单表:树形查询
ALTER TABLE think_menu ADD INDEX idx_parent_id (parent_id);
ALTER TABLE laravel_menu ADD INDEX idx_parent_id (parent_id);
-- 字典项表:按字典 ID 查询
ALTER TABLE think_dict_item ADD INDEX idx_dict_id (dict_id);
ALTER TABLE think_config_item ADD INDEX idx_config_id (config_id);
ALTER TABLE laravel_dict_item ADD INDEX idx_dict_id (dict_id);
ALTER TABLE laravel_config_item ADD INDEX idx_config_id (config_id);
-- 城市表:按编码查询
ALTER TABLE think_city ADD INDEX idx_area_code (area_code);
ALTER TABLE think_city ADD INDEX idx_parent_code (parent_code);
ALTER TABLE think_city ADD INDEX idx_pid (pid);
ALTER TABLE laravel_city ADD INDEX idx_area_code (area_code);
ALTER TABLE laravel_city ADD INDEX idx_parent_code (parent_code);
```
### 索引设计原则
@@ -268,21 +266,22 @@ ALTER TABLE think_city ADD INDEX idx_pid (pid);
|------|------|
| 外键必加 | 所有 `_id` 外键字段都应加索引 |
| 过滤字段 | `pageEqFields` 中配置的精确匹配字段应加索引 |
| 模糊搜索 | `pageLikeFields` 中的字段可考虑全文索引,但 LIKE '%xx%' 无法用普通索引 |
| 模糊搜索 | `pageLikeFields` 中的字段可考虑全文索引,但 `LIKE '%xx%'` 无法用普通索引 |
| 排序字段 | `pageOrderBy` 中的默认排序字段可加索引 |
| 时间字段 | 日志表的 `create_time` 必加索引(按时间范围查询) |
| 联合唯一 | 软删除表的唯一约束必须含 `is_delete`(见上文) |
## 多数据库支持
### 支持的数据库
| 数据库 | type 值 | 默认端口 | 默认用户 | PHP 扩展 |
|--------|---------|----------|----------|----------|
| MySQL | `mysql` | 3306 | root | `pdo_mysql` |
| PostgreSQL | `pgsql` | 5432 | postgres | `pdo_pgsql` |
| SQL Server | `sqlsrv` | 1433 | sa | `pdo_sqlsrv` |
| Oracle | `oracle` | 1521 | - | `pdo_oci` |
| SQLite | `sqlite` | - | - | `pdo_sqlite` |
| 数据库 | `DB_CONNECTION` 值 | 默认端口 | PHP 扩展 |
|--------|---------|----------|----------|
| MySQL | `mysql` | 3306 | `pdo_mysql` |
| PostgreSQL | `pgsql` | 5432 | `pdo_pgsql` |
| SQL Server | `sqlsrv` | 1433 | `pdo_sqlsrv` |
| Oracle | `oracle` | 1521 | `pdo_oci`(自定义 oci 驱动接入) |
| SQLite | `sqlite` | - | `pdo_sqlite` |
### 切换数据库
@@ -291,49 +290,49 @@ ALTER TABLE think_city ADD INDEX idx_pid (pid);
::: code-group
```ini [MySQL(默认)]
[DATABASE]
TYPE = mysql
HOSTNAME = 127.0.0.1
DATABASE = rxthinkcmf.thinkphp6.elevue
USERNAME = root
PASSWORD = root
HOSTPORT = 3306
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=rxthinkcmf.laravel13.elevue
DB_USERNAME=root
DB_PASSWORD=root
DB_PREFIX=laravel_
```
```ini [PostgreSQL]
[DATABASE]
TYPE = pgsql
HOSTNAME = 127.0.0.1
DATABASE = rxthinkcmf.thinkphp6.elevue
USERNAME = postgres
PASSWORD = root
HOSTPORT = 5432
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=rxthinkcmf.laravel13.elevue
DB_USERNAME=postgres
DB_PASSWORD=root
DB_PREFIX=laravel_
```
```ini [SQL Server]
[DATABASE]
TYPE = sqlsrv
HOSTNAME = 127.0.0.1
DATABASE = rxthinkcmf.thinkphp6.elevue
USERNAME = sa
PASSWORD = root
HOSTPORT = 1433
DB_CONNECTION=sqlsrv
DB_HOST=127.0.0.1
DB_PORT=1433
DB_DATABASE=rxthinkcmf.laravel13.elevue
DB_USERNAME=sa
DB_PASSWORD=root
DB_PREFIX=laravel_
```
```ini [Oracle]
[DATABASE]
TYPE = oracle
HOSTNAME = 127.0.0.1
DATABASE = rxthinkcmf.thinkphp6.elevue
USERNAME = root
PASSWORD = root
HOSTPORT = 1521
DB_CONNECTION=oracle
DB_HOST=127.0.0.1
DB_PORT=1521
DB_DATABASE=rxthinkcmf.laravel13.elevue
DB_USERNAME=root
DB_PASSWORD=root
DB_PREFIX=laravel_
```
```ini [SQLite]
[DATABASE]
TYPE = sqlite
DATABASE = /www/api/runtime/database.sqlite
DB_CONNECTION=sqlite
# DB_DATABASE 指向 database/database.sqlite(默认值)
DB_PREFIX=laravel_
```
:::
@@ -343,22 +342,26 @@ DATABASE = /www/api/runtime/database.sqlite
```php
// config/database.php
return [
'default' => env('database.type', 'mysql'),
'default' => env('DB_CONNECTION', 'mysql'),
'connections' => [
'mysql' => [...],
'pgsql' => [...],
'sqlsrv' => [...],
'oracle' => [...],
'sqlite' => [...],
'sqlite' => [...],
'mysql' => [...],
'pgsql' => [...],
'sqlsrv' => [...],
'oracle' => [...], // 自定义 oci 驱动(AppServiceProvider 注册 DB::extend)
],
];
```
:::tip Oracle 接入说明
Laravel 核心不内置 Oracle 连接器,项目通过 `AppServiceProvider` 中 `DB::extend('oci', ...)` 注册自定义驱动,配套 `App\Support\Database\{OracleConnection, OracleGrammar, OracleProcessor}` 完成分页语法与自增主键回填适配。切换前需安装 `pdo_oci` 扩展(见 [2.1 开发环境准备](/zh/quickstart/environment.html))。
:::
### 字段类型映射
| 通用类型 | MySQL | PostgreSQL | SQL Server | Oracle | SQLite |
|---------|-------|------------|------------|--------|--------|
| 自增主键 | `INT AUTO_INCREMENT` | `SERIAL` | `INT IDENTITY` | `NUMBER` + 序列 | `INTEGER PRIMARY KEY` |
| 自增主键 | `INT AUTO_INCREMENT` | `SERIAL` | `INT IDENTITY` | `NUMBER` + 序列/触发器 | `INTEGER PRIMARY KEY` |
| 字符串 | `VARCHAR(n)` | `VARCHAR(n)` | `NVARCHAR(n)` | `VARCHAR2(n)` | `TEXT` |
| 文本 | `TEXT` | `TEXT` | `NVARCHAR(MAX)` | `CLOB` | `TEXT` |
| 布尔 | `TINYINT(1)` | `BOOLEAN` | `BIT` | `NUMBER(1)` | `INTEGER` |
@@ -366,352 +369,12 @@ return [
| 小数 | `DECIMAL(m,n)` | `NUMERIC(m,n)` | `DECIMAL(m,n)` | `NUMBER(m,n)` | `REAL` |
:::tip 业务代码零修改
ThinkPHP 的 ORM 层会自动处理大部分 SQL 差异,切换数据库只需修改 `.env` 中的 `DATABASE.TYPE`,业务代码无需任何修改。
Eloquent 的方言层自动处理大部分 SQL 差异,切换数据库只需修改 `.env` 中的 `DB_CONNECTION`,业务代码无需任何修改。跨库 DDL 由 `DbSchemaBuilder` 按驱动生成(Oracle 自增序列与触发器自动创建)。
:::
### 各数据库安装与配置
### 各数据库安装
项目支持五种数据库,按需选择一种即可。
| 数据库 | 推荐场景 | 难度 | XAMPP 自带 | 说明 |
|--------|---------|------|-----------|------|
| **MySQL** | 日常开发、中小项目 | ⭐ 低 | ✅ 是 | 默认推荐,开箱即用 |
| **PostgreSQL** | 需要 JSONB、全文检索 | ⭐⭐ 中 | ❌ 否 | 功能丰富,需单独安装 |
| **SQLite** | 快速演示、单元测试 | ⭐ 低 | ✅ 是 | 无需数据库服务,零配置 |
| **SQL Server** | 企业 Windows 环境 | ⭐⭐ 中 | 驱动自带 | 需单独安装数据库服务 |
| **Oracle** | 大型企业项目 | ⭐⭐⭐ 高 | 驱动自带 | 需安装 Instant Client |
::: tip 新手推荐
XAMPP 已集成 MySQL 数据库服务,安装 XAMPP 后无需再单独安装数据库。
:::
#### MySQL 数据库
MySQL 是项目默认推荐的关系型数据库,XAMPP 已自带,开箱即用。
::: code-group
```sh [Windows]
# XAMPP 已自带 MySQL,无需额外安装
# 在 XAMPP 控制面板启动 MySQL 即可
# 确认扩展已启用(编辑 D:\xampp8.2\php\php.ini):
# extension=pdo_mysql
# 若前面有分号则去掉,保存后重启 XAMPP
# 创建数据库
D:\xampp8.2\mysql\bin\mysql.exe -u root -proot -e "CREATE DATABASE `rxthinkcmf.thinkphp6.elevue` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;"
```
```sh [macOS]
brew install mysql
brew services start mysql
sudo mysql_secure_installation
# 创建数据库
mysql -u root -proot -e "CREATE DATABASE `rxthinkcmf.thinkphp6.elevue` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;"
```
```sh [Ubuntu / Debian]
sudo apt install mysql-server
sudo systemctl start mysql
sudo systemctl enable mysql
sudo mysql_secure_installation
# 创建数据库
mysql -u root -proot -e "CREATE DATABASE `rxthinkcmf.thinkphp6.elevue` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;"
```
```sh [Rocky Linux / CentOS]
sudo dnf install mysql-server
sudo systemctl start mysqld
sudo systemctl enable mysqld
sudo mysql_secure_installation
# 创建数据库
mysql -u root -proot -e "CREATE DATABASE `rxthinkcmf.thinkphp6.elevue` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;"
```
:::
#### PostgreSQL 数据库
PostgreSQL 功能强大,支持 JSONB、全文检索等高级特性,需单独安装。
::: code-group
```sh [Windows]
# 安装 PostgreSQL
# 访问 https://www.postgresql.org/download/windows/ 下载安装包
# 安装过程中记住设置的密码
# 确认扩展已启用(编辑 D:\xampp8.2\php\php.ini):
# extension=pdo_pgsql
# extension=pgsql
# 若前面有分号则去掉,保存后重启 XAMPP
# 设置 postgres 密码(首次)
psql -U postgres -c "ALTER USER postgres WITH PASSWORD 'root';"
# 创建数据库
psql -U postgres -c "CREATE DATABASE \"rxthinkcmf.thinkphp6.elevue\" OWNER postgres ENCODING 'UTF8';"
```
```sh [macOS]
brew install postgresql
brew services start postgresql
# 设置 postgres 密码
sudo -u postgres psql -c "ALTER USER postgres WITH PASSWORD 'root';"
# 创建数据库
sudo -u postgres psql -c "CREATE DATABASE \"rxthinkcmf.thinkphp6.elevue\" OWNER postgres ENCODING 'UTF8';"
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE \"rxthinkcmf.thinkphp6.elevue\" TO postgres;"
```
```sh [Ubuntu / Debian]
sudo apt install postgresql postgresql-contrib
sudo systemctl start postgresql
# 设置 postgres 密码
sudo -u postgres psql -c "ALTER USER postgres WITH PASSWORD 'root';"
# 创建数据库
sudo -u postgres psql -c "CREATE DATABASE \"rxthinkcmf.thinkphp6.elevue\" OWNER postgres ENCODING 'UTF8';"
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE \"rxthinkcmf.thinkphp6.elevue\" TO postgres;"
```
```sh [Rocky Linux / CentOS]
sudo dnf install postgresql-server
sudo postgresql-setup --initdb
sudo systemctl start postgresql
# 设置 postgres 密码
sudo -u postgres psql -c "ALTER USER postgres WITH PASSWORD 'root';"
# 创建数据库
sudo -u postgres psql -c "CREATE DATABASE \"rxthinkcmf.thinkphp6.elevue\" OWNER postgres ENCODING 'UTF8';"
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE \"rxthinkcmf.thinkphp6.elevue\" TO postgres;"
```
:::
#### SQLite 数据库
SQLite 无需安装数据库服务器,数据存储在单个文件中,适合开发环境和小型项目。
::: code-group
```sh [Windows]
# XAMPP 已自带 SQLite 扩展,无需额外安装
# 只需创建数据库文件
mkdir D:\xampp\htdocs\runtime
echo. > D:\xampp\htdocs\runtime\database.sqlite
# 确认扩展已启用(编辑 D:\xampp8.2\php\php.ini):
# extension=pdo_sqlite
# extension=sqlite3
# 若前面有分号则去掉,保存后重启 XAMPP
# .env 配置
# DATABASE.TYPE = sqlite
# DATABASE.DATABASE = D:/xampp8.2/htdocs/runtime/database.sqlite
```
```sh [macOS]
brew install php sqlite
# 创建数据库文件
mkdir -p /www/api/runtime
touch /www/api/runtime/database.sqlite
```
```sh [Ubuntu / Debian]
sudo apt install php8.2-sqlite3
# 创建数据库文件
mkdir -p /www/api/runtime
touch /www/api/runtime/database.sqlite
```
```sh [Rocky Linux / CentOS]
sudo dnf install php-sqlite3
# 创建数据库文件
mkdir -p /www/api/runtime
touch /www/api/runtime/database.sqlite
```
:::
#### SQL Server 数据库
SQL Server 是微软商业数据库,XAMPP 已自带 pdo_sqlsrv 驱动,Windows 下开箱即用。
::: code-group
```sh [Windows]
# SQL Server 需单独安装(XAMPP 不自带数据库服务,但自带 PHP 驱动)
# 1. 下载安装 SQL Server Express:https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads
# 2. XAMPP 已自带 pdo_sqlsrv 扩展,在 php.ini 中启用:
# extension=php_sqlsrv_82_ts_x64.dll
# extension=php_pdo_sqlsrv_82_ts_x64.dll
# 创建数据库
sqlcmd -S localhost -U sa -P root -Q "CREATE DATABASE [rxthinkcmf.thinkphp6.elevue] COLLATE Chinese_PRC_CI_AS"
```
```sh [macOS]
# macOS 不原生支持 SQL Server,建议使用 Docker
docker run -e "ACCEPT_EULA=Y" -e "SA_PASSWORD=root" \
-p 1433:1433 --name sqlserver \
-d mcr.microsoft.com/mssql/server:2022-latest
# 安装 PHP 扩展
pecl install pdo_sqlsrv
# 创建数据库
sqlcmd -S localhost -U sa -P root -Q "CREATE DATABASE [rxthinkcmf.thinkphp6.elevue] COLLATE Chinese_PRC_CI_AS"
```
```sh [Ubuntu / Debian]
# 1. 安装 SQL Server
curl -sSL https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add -
sudo add-apt-repository "$(wget -qO- https://packages.microsoft.com/config/ubuntu/22.04/mssql-server-2022.list)"
sudo apt update
sudo apt install -y mssql-server
sudo /opt/mssql/bin/mssql-conf setup
# 2. 安装命令行工具
sudo apt install -y mssql-tools18
echo 'export PATH="$PATH:/opt/mssql-tools18/bin"' >> ~/.bashrc
source ~/.bashrc
# 3. 安装 PHP 扩展
sudo pecl install pdo_sqlsrv
echo "extension=pdo_sqlsrv.so" | sudo tee /etc/php/8.2/mods-available/pdo_sqlsrv.ini
sudo phpenmod pdo_sqlsrv
# 创建数据库
sqlcmd -S localhost -U sa -P root -Q "CREATE DATABASE [rxthinkcmf.thinkphp6.elevue] COLLATE Chinese_PRC_CI_AS"
```
```sh [Rocky Linux / CentOS]
# 1. 安装 SQL Server
sudo curl -o /etc/yum.repos.d/mssql-server.repo https://packages.microsoft.com/config/rhel/8/mssql-server-2022.repo
sudo dnf install -y mssql-server
sudo /opt/mssql/bin/mssql-conf setup
# 2. 安装命令行工具
sudo curl -o /etc/yum.repos.d/msprod.repo https://packages.microsoft.com/config/rhel/8/prod.repo
sudo dnf install -y mssql-tools18
echo 'export PATH="$PATH:/opt/mssql-tools18/bin"' >> ~/.bashrc
source ~/.bashrc
# 3. 安装 PHP 扩展
sudo pecl install pdo_sqlsrv
echo "extension=pdo_sqlsrv.so" | sudo tee /etc/php.d/pdo_sqlsrv.ini
# 创建数据库
sqlcmd -S localhost -U sa -P root -Q "CREATE DATABASE [rxthinkcmf.thinkphp6.elevue] COLLATE Chinese_PRC_CI_AS"
```
:::
#### Oracle 数据库
Oracle 是企业级商业数据库,需手动安装 Instant Client 和 pdo_oci 扩展。
::: code-group
```sh [Windows]
# XAMPP 已自带 php_pdo_oci.dll,只需安装 Oracle Instant Client 并在 php.ini 中启用
# 1. 安装 Oracle Instant Client
# 下载地址:https://www.oracle.com/database/technologies/instant-client/downloads.html
# 选择 Basic Package,x64,推荐 19c
# 解压到 C:\oracle\instantclient_19_20(⚠️ 避免空格与中文)
# 2. 配置系统环境变量
# 将 C:\oracle\instantclient_19_20 添加到系统 PATH
# 3. 安装 VC++ 运行库(必装)
# 下载:https://aka.ms/vc14/vc_redist.x64.exe
# 4. 启用扩展
# 编辑 D:\xampp8.2\php\php.ini,去掉分号:
# extension=pdo_oci
# 5. 重启 XAMPP(完全关闭再启动)
# .env 配置
# DATABASE.TYPE = oracle
# DATABASE.HOSTNAME = 127.0.0.1
# DATABASE.DATABASE = rxthinkcmf.thinkphp6.elevue
# DATABASE.USERNAME = root
# DATABASE.PASSWORD = root
# DATABASE.HOSTPORT = 1521
```
```sh [macOS]
# Oracle 不原生支持 macOS,建议使用 Docker
docker run -d --name oracle-xe -p 1521:1521 \
-e ORACLE_PWD=root \
gvenzl/oracle-xe:21-slim
# 安装 PHP 扩展
pecl install pdo_oci
```
```sh [Ubuntu / Debian]
# 1. 安装 Oracle Database
sudo dpkg -i oracle-database-xe-21c-1.0-1.x86_64.deb
sudo /etc/init.d/oracle-xe-21c configure
# 2. 安装 Oracle Instant Client
sudo dpkg -i oracle-instantclient-basic-*.deb
sudo dpkg -i oracle-instantclient-devel-*.deb
# 3. 安装 PHP 扩展
sudo pecl install pdo_oci
echo "extension=pdo_oci.so" | sudo tee /etc/php/8.2/mods-available/pdo_oci.ini
sudo phpenmod pdo_oci
# 4. 创建表空间和用户
sqlplus system/root@localhost:1521/XE <<EOF
CREATE TABLESPACE "rxthinkcmf_thinkphp6_elevue" DATAFILE '/opt/oracle/oradata/XE/rxthinkcmf_thinkphp6_elevue.dbf' SIZE 100M AUTOEXTEND ON NEXT 100M;
CREATE USER "root" IDENTIFIED BY "root" DEFAULT TABLESPACE "rxthinkcmf_thinkphp6_elevue";
GRANT CONNECT, RESOURCE, CREATE VIEW TO "root";
GRANT UNLIMITED TABLESPACE TO "root";
EXIT;
EOF
```
```sh [Rocky Linux / CentOS]
# 1. 安装 Oracle Database
sudo rpm -ivh oracle-database-xe-21c-1.0-1.x86_64.rpm
sudo /etc/init.d/oracle-xe-21c configure
# 2. 安装 Oracle Instant Client
sudo rpm -ivh oracle-instantclient-basic-*.rpm
sudo rpm -ivh oracle-instantclient-devel-*.rpm
# 3. 安装 PHP 扩展
sudo pecl install pdo_oci
echo "extension=pdo_oci.so" | sudo tee /etc/php/8.2/mods-available/pdo_oci.ini
sudo phpenmod pdo_oci
# 4. 创建表空间和用户
sqlplus system/root@localhost:1521/XE <<EOF
CREATE TABLESPACE "rxthinkcmf_thinkphp6_elevue" DATAFILE '/opt/oracle/oradata/XE/rxthinkcmf_thinkphp6_elevue.dbf' SIZE 100M AUTOEXTEND ON NEXT 100M;
CREATE USER "root" IDENTIFIED BY "root" DEFAULT TABLESPACE "rxthinkcmf_thinkphp6_elevue";
GRANT CONNECT, RESOURCE, CREATE VIEW TO "root";
GRANT UNLIMITED TABLESPACE TO "root";
EXIT;
EOF
```
:::
数据库的安装与初始化步骤已在 [2.1 开发环境准备](/zh/quickstart/environment.html)(含四平台安装命令、扩展启用、Oracle Instant Client)与 [2.5 数据库初始化](/zh/quickstart/database-init.html)(建库、脚本导入、`.env` 配置)中完整说明,此处不再重复。
## SQL 脚本
@@ -720,30 +383,32 @@ EOF
```text
document/
├── mysql/
│ └── rxthinkcmf.thinkphp6.elevue.sql # MySQL 版本
│ └── rxthinkcmf.laravel13.elevue.sql # MySQL 版本
├── postgresql/
│ └── rxthinkcmf.thinkphp6.elevue.sql # PostgreSQL 版本
│ └── rxthinkcmf.laravel13.elevue.sql # PostgreSQL 版本
├── sqlserver/
│ └── rxthinkcmf.thinkphp6.elevue.sql # SQL Server 版本
│ └── rxthinkcmf.laravel13.elevue.sql # SQL Server 版本
├── oracle/
│ └── rxthinkcmf.thinkphp6.elevue.sql # Oracle 版本
│ └── rxthinkcmf.laravel13.elevue.sql # Oracle 版本
└── sqlite/
└── rxthinkcmf.thinkphp6.elevue.sql # SQLite 版本
└── rxthinkcmf.laravel13.elevue.sql # SQLite 版本
```
各数据库脚本包含完整的表结构定义和初始数据(用户、角色、菜单、字典、城市行政区划等),已针对各数据库的 SQL 语法做了适配,可直接导入使用。
导入方法详见 [2.4 数据库初始化](/zh/quickstart/database-init.html)。
:::warning
本项目 `database/migrations/` 为空,**建表以 SQL 脚本为准**,`php artisan migrate` 不会创建业务表。导入方法详见 [2.5 数据库初始化](/zh/quickstart/database-init.html)。
:::
## 慢查询排查
```ini
# MySQL my.cnf
# MySQL my.cnf / my.ini
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 2
```
:::warning 注意
项目所有查询都通过 ThinkPHP ORM 构建,不会出现原生 SQL 注入。如需排查慢查询,建议开启数据库慢查询日志,而非在代码层面埋点。
项目所有查询都通过 Eloquent 查询构建器构建(参数绑定),不会出现原生 SQL 注入。如需排查慢查询,建议开启数据库慢查询日志,而非在代码层面埋点;同时核对 [索引策略](#索引策略) 中的推荐索引是否缺失。
:::
@@ -1,7 +1,7 @@
# 4.5 目录结构设计
:::tip 概述
目录结构遵循"按职责分层、按模块分组"的原则。后端以 `app/` 为核心,按 Controller → Logic → Model → Validate 四层组织;前端以 `src/` 为核心,按 API → Views → Store 分层。两侧通过路由和 API 接口对接。
目录结构遵循"按职责分层、按模块分组"的原则。后端以 `app/` 为核心,按 Controller → Request → Service → Model 四层组织;前端以 `src/` 为核心,按 API → Views → Store 分层。两侧通过路由和 API 接口对接。
:::
## 后端整体结构
@@ -9,57 +9,65 @@
```text
项目根目录/
├── app/ ← 应用代码(核心)
├── config/ ← 配置文件
├── extend/ ← 扩展类库(非 Composer)
├── bootstrap/ ← 应用引导(中间件别名/分组、健康检查路由)
├── config/ ← 配置文件(18 个,7 个独立业务配置)
├── database/ ← 迁移(空)/ 种子 / 工厂
├── docker/ ← Docker 部署文件
├── public/ ← Web 入口 + 静态资源
├── route/ ← 路由定义
├── templates/ ← 代码生成器模板
├── runtime/ ← 运行时缓存(自动生成)
├── resources/ ← 生成器模板(templates/)+ Blade 视图 + Vite 资源
├── routes/ ← 路由定义(api / web / console)
├── storage/ ← 运行时产物(日志/缓存/会话,需可写)
├── tests/ ← PHPUnit 测试
├── uploads/ ← 上传文件目录
├── vendor/ ← Composer 依赖
├── wiki/ ← 文档站点(VitePress)
├── document/ ← 开发参考手册(txt)
├── .env ← 环境变量配置
└── think ← ThinkPHP 命令行入口
├── document/ ← 五种数据库初始化脚本
├── artisan ← Artisan 命令行入口
└── .env ← 环境变量配置
```
## app/ 目录详解
```text
app/
├── BaseController.php 控制器基类
├── BaseModel.php 模型基类
├── BaseLogic.php 业务逻辑基类(系统共享模块)
├── BaseTenantLogic.php 租户逻辑基类(租户隔离模块)
├── ExceptionHandle.php 统一异常处理(JSON 响应)
├── Request.php 请求类扩展
├── common.php 公共函数库
├── event.php 事件定义
├── middleware.php 全局中间件注册
│
├── controller/ 控制器层(28 个)
├── logic/ 业务逻辑层(26 个)
├── model/ 数据模型层(25 个)
├── validate/ 验证器(21 个)
├── service/ 通用服务层(11 个)
├── middleware/ 中间件(5 个)
├── attribute/ 注解定义(3 个)
├── command/ 命令行命令(4 个)
└── task/ 定时任务处理器(3 个)
├── Attributes/ 注解定义(3 个)
│ ├── Permission.php 权限校验注解
│ ├── OperationLog.php 操作日志注解
│ └── DemoAllow.php 演示环境放行注解
├── Console/Commands/ 命令行命令(5 个)
├── Exceptions/ 异常处理
│ ├── BizException.php 业务异常
│ └── Handler.php 统一异常渲染(JSON)
├── Helpers/helpers.php 全局辅助函数(21 个)
├── Http/
│ ├── Controllers/
│ │ ├── Controller.php 控制器基类
│ │ └── Api/ 控制器层(28 个)
│ ├── Middleware/ 中间件(7 个)
│ └── Requests/ 表单请求(24 个 + BaseRequest)
├── Models/ 数据模型层(25 个)
│ └── Concerns/ 模型 Trait(3 个)
├── Observers/ 模型观察者(3 个,缓存失效)
├── Providers/ 服务提供者(启动校验 + Observer + oci 驱动注册)
├── Services/ 服务层(36 个)
│ └── Concerns/ Service Trait(7 个)
├── Support/ 基础支撑(Auth / Result)
└── Task/ 定时任务处理器(3 个)
```
### 各目录职责与命名规范
| 目录 | 职责 | 命名规则 | 数量 | 说明 |
|------|------|----------|------|------|
| `controller/` | HTTP 请求入口 | `{Module}Controller.php` | 28 | 一个模块一个文件,注解标注权限和日志 |
| `logic/` | 业务逻辑 | `{Module}Logic.php` | 26 | 属性配置 + 生命周期钩子,核心业务层 |
| `model/` | 数据库映射 | `{TableName}.php`(大驼峰) | 25 | 继承 BaseModel,获软删除 + 审计字段 |
| `validate/` | 参数校验 | `{Module}Validate.php` | 21 | 定义规则 + 场景,Logic 层自动调用 |
| `service/` | 通用服务 | `{Name}Service.php` | 11 | 跨模块复用的无状态工具类 |
| `middleware/` | 中间件 | `{Name}Middleware.php` | 5 | 请求拦截:认证、租户、日志等 |
| `attribute/` | 注解类 | `{Name}.php` | 3 | PHP 8 注解:Log、Permission、DemoAllow |
| `command/` | 命令行 | `{Name}Command.php` | 4 | CLI 命令:定时任务、代码生成、迁移 |
| `task/` | 任务处理器 | `{Name}Task.php` | 3 | 定时任务的具体执行逻辑 |
| `Http/Controllers/Api/` | HTTP 请求入口 | `{Module}Controller.php` | 28 | 一个模块一个文件,注解标注权限和日志 |
| `Http/Requests/` | 参数格式校验 | `{Module}Request.php` | 24+1 | 继承 BaseRequest,控制器类型注入 |
| `Services/` | 业务逻辑 | `{Module}Service.php` | 36 | 属性配置 + 生命周期钩子,核心业务层 |
| `Models/` | 数据库映射 | `{TableName}.php`(大驼峰) | 25 | 模型 Trait 获软删除 + 审计字段 |
| `Http/Middleware/` | 中间件 | 见下方清单 | 7 | 请求拦截:认证、租户、日志等 |
| `Attributes/` | 注解类 | `{Name}.php` | 3 | Permission、OperationLog、DemoAllow |
| `Console/Commands/` | 命令行 | `{Name}Command.php` 等 | 5 | CLI 命令:生成器、迁移、任务、字典缓存 |
| `Task/` | 任务处理器 | `{Name}Task.php` | 3 | 定时任务的具体执行逻辑 |
| `Observers/` | 缓存失效 | `{Name}Observer.php` | 3 | 字典/菜单/角色菜单变更 → 清缓存 |
| `Support/` | 基础支撑 | `Auth.php` / `Result.php` | 2 | 当前登录用户 / 统一响应 |
### 模块文件对应关系
@@ -67,141 +75,110 @@ app/
```text
app/
├── controller/UserController.php ← HTTP 入口(路由指向这里)
├── logic/UserLogic.php ← 业务逻辑(属性配置 + 钩子)
├── model/User.php ← 数据模型(表映射)
└── validate/UserValidate.php ← 参数校验(规则 + 场景)
├── Http/Controllers/Api/UserController.php ← HTTP 入口(路由指向这里)
├── Http/Requests/UserRequest.php ← 参数校验(规则 + 中文消息)
├── Services/UserService.php ← 业务逻辑(属性配置 + 钩子)
└── Models/User.php ← 数据模型(表映射 + Trait)
```
请求流转:`Controller → Logic → Model → 数据库`
请求流转:`Controller → (FormRequest 校验) → Service → Model → 数据库`
### 基类与公共文件
| 文件 | 说明 |
|------|------|
| `BaseController.php` | 控制器基类:统一响应、参数获取、通用 CRUD 快捷方法 |
| `BaseLogic.php` | 逻辑基类:声明式 CRUD、生命周期钩子、自动参数校验、文件/富文本/枚举/权限/租户处理 |
| `BaseTenantLogic.php` | 租户逻辑基类:继承 BaseLogic,自动启用 `tenant_id` 隔离 |
| `BaseModel.php` | 模型基类:软删除(`is_delete`)、审计字段自动写入 |
| `ExceptionHandle.php` | 异常处理:所有异常统一渲染为 JSON 响应 |
| `common.php` | 公共函数:字符串处理、加密、文件操作、驼峰转换等 |
| `event.php` | 事件定义:全局事件监听 |
| `middleware.php` | 全局中间件注册:CORS 等 |
| `Http/Controllers/Controller.php` | 控制器基类:`success/fail/paginate` 响应 + `_page/_add` 等通用 CRUD 快捷方法 + 参数获取 |
| `Http/Requests/BaseRequest.php` | 请求基类:授权统一放行、失败统一 JSON |
| `Services/BaseService.php` | 服务基类:**全部声明式属性集中声明** + Trait 占位方法 + 处理链 |
| `Services/BaseTenantService.php` | 租户服务基类:继承 BaseService,自动 `tenant_id` 查询/写入隔离 |
| `Exceptions/Handler.php` | 异常处理:`api/*` 异常统一渲染为 JSON 响应 |
| `Exceptions/BizException.php` | 业务异常:Service 抛出,默认 `code=1` |
| `Support/Result.php` | 统一响应:`success/page/fail/unauthorized/forbidden/notFound/validateError` |
| `Support/Auth.php` | 当前登录用户:`Auth::userId()` / `Auth::username()` |
| `Helpers/helpers.php` | 公共函数:驼峰转换、文件处理、字符串等 21 个(composer autoload.files) |
### service/ 服务层清单
### Services 横切服务清单
服务层提供跨模块复用的无状态能力,按单一职责拆分:
跨模块复用的无状态能力(业务 Service 之外):
| 服务 | 文件 | 说明 |
|------|------|------|
| JWT 认证 | `JwtService.php` | 登录、刷新、解析 Token |
| 密码加密 | `PasswordService.php` | bcrypt 双重加盐 |
| 字典服务 | `DictService.php` | 字典缓存与查询(两级缓存) |
| 系统参数 | `ParamService.php` | 系统参数服务(两级缓存) |
| 验证码 | `CaptchaService.php` | 图片验证码生成与校验 |
| 请求信息 | `RequestInfoService.php` | 客户端 IP、UA、归属地解析 |
| 注解读取 | `AttributeService.php` | PHP 8 注解反射读取 + 缓存 |
| JWT 认证 | `JwtService.php` | 登录、刷新、解析、踢下线(Redis 有状态) |
| 密码加密 | `PasswordService.php` | bcrypt(成本因子 12) |
| 字典服务 | `DictCacheService.php` | 字典缓存与查询(两级缓存) |
| 验证码 | `CaptchaService.php` | 图片验证码生成与校验(IP 限流) |
| 请求信息 | `RequestInfoService.php` | 客户端 IP、UA、归属地解析(ip2region) |
| 代码生成 | `GeneratorService.php` | 根据数据库表生成 CRUD 代码 |
| 数据库迁移 | `DbMigrateService.php` | 跨数据库迁移引擎 |
| DDL 生成 | `DbSchemaBuilder.php` | 跨库类型映射的 DDL 生成器 |
| Excel | `ExcelService.php` | 导入导出(支持大数据量分批) |
| 数据库迁移 | `DbMigrateService.php` | 跨数据库迁移引擎(五驱动) |
| DDL 生成 | `DbSchemaBuilder.php` | 跨库类型映射的 DDL 生成器(Oracle 序列/触发器) |
| Excel | `ExcelService.php` | 导入导出(表头映射、分批) |
| 配置管理 | `ConfigService.php` | 系统配置读写与缓存 |
| 字典管理 | `DictService.php` | 字典/字典项管理 |
### middleware/ 中间件清单
### Middleware 中间件清单
| 中间件 | 文件 | 作用域 | 说明 |
|--------|------|--------|------|
| 跨域 | `CorsMiddleware.php` | 全局 | CORS 头设置 |
| 认证权限 | `AuthMiddleware.php` | 路由级 | JWT 校验 + 权限校验 |
| 租户上下文 | `TenantMiddleware.php` | 路由级 | 解析注入 tenantId |
| 演示环境 | `DemoMiddleware.php` | 路由级 | 演示环境写操作拦截 |
| 操作日志 | `LogMiddleware.php` | 路由级 | 基于注解记录操作日志 |
| 中间件 | 文件 | 别名 | 作用域 | 说明 |
|--------|------|------|--------|------|
| 真实 IP | `ProxyMiddleware.php` | proxy | 全局(api prepend) | 受信代理 `X-Forwarded-For` 解析 |
| 跨域 | `Cors.php` | cors | 全局 | CORS 头设置(`config/cors.php`) |
| 强制 JSON | `ForceJsonResponse.php` | force.json | 全局 | 规范化 Accept 头 |
| 认证权限 | `Authenticate.php` | auth.jwt | 路由组 | JWT 校验 + `#[Permission]` 鉴权 |
| 租户上下文 | `ResolveTenant.php` | tenant | 路由组 | 解析注入 tenantId |
| 操作日志 | `LogOperation.php` | log.operation | 路由组 | 基于 `#[OperationLog]` 采集,terminable 落库 |
| 演示环境 | `DemoMiddleware.php` | demo | 路由别名 | 写操作拦截,`#[DemoAllow]` 放行 |
### command/ 命令行清单
### Console 命令清单
| 命令 | 文件 | 说明 |
|------|------|------|
| 定时任务执行 | `JobRunCommand.php` | 执行一次定时任务 |
| 定时任务守护 | `JobDaemonCommand.php` | 常驻进程循环扫描执行 |
| 代码生成 | `GeneratorCommand.php` | CLI 方式生成 CRUD 代码 |
| 数据库迁移 | `DbMigrateCommand.php` | 跨数据库迁移 CLI 工具 |
| 命令 | 文件 | 签名 | 说明 |
|------|------|------|------|
| 代码生成 | `GeneratorCommand.php` | `generate:code` | CLI 方式生成 CRUD 代码 |
| 数据库迁移 | `DbMigrateCommand.php` | `db:migrate` | 跨数据库迁移 CLI 工具 |
| 定时任务执行 | `JobRunCommand.php` | `job:run` | 执行一次到期任务(配合系统 cron) |
| 定时任务守护 | `JobDaemonCommand.php` | `job:daemon` | 常驻进程循环扫描执行 |
| 字典缓存清理 | `ClearDictCache.php` | `dict:clear-cache` | 清除字典缓存 |
## config/ 配置文件
```text
config/
├── api.php API 配置(驼峰转换开关)
├── app.php 应用配置(调试模式、异常处理)
├── cache.php 缓存配置(支持 Redis)
├── console.php 命令行配置
├── cors.php 跨域配置
├── database.php 数据库配置
├── file.php 文件上传配置(域名、大小限制)
├── jwt.php JWT 配置(密钥、过期时间)
├── middleware.php 中间件排除列表
└── route.php 路由配置
├── api.php API 配置(驼峰转换开关)★
├── captcha.php 验证码配置 ★
├── cors.php 跨域配置 ★
├── file.php 文件上传配置(域名、大小限制)★
├── jwt.php JWT 配置(密钥、过期时间)★
├── middleware.php 中间件配置(受信代理)★
├── tenant.php 多租户配置 ★
├── generator.php 代码生成器配置
├── app.php / auth.php / cache.php / database.php / filesystems.php
├── logging.php / mail.php / queue.php / services.php / session.php
```
| 配置文件 | 关键配置项 | 说明 |
|----------|-----------|------|
| `api.php` | `camel_snake_convert` | 驼峰/下划线自动转换开关 |
| `database.php` | `type` / `hostname` / `database` | 数据库连接(支持 MySQL/PG/SqlServer/Oracle/SQLite) |
| `file.php` | `domain_url` / `max_size` | 文件上传域名和大小限制 |
| `jwt.php` | `secret` / `access_ttl` / `refresh_ttl` | JWT 密钥和令牌有效期 |
| `cache.php` | `type` / `host` | 缓存驱动(file/redis) |
标 ★ 为本项目独立维护的配置文件(不与 Laravel 默认配置合并)。
## extend/ 扩展类库
存放不通过 Composer 管理的自定义类库,按 PSR-4 自动加载:
## routes/ 路由定义
```text
extend/
├── jwt/
│ └── Jwt.php JWT 工具类(封装 firebase/php-jwt)
├── response/
│ └── Result.php 统一响应类(success/fail/page 等)
└── (无自定义扩展类)
routes/
├── api.php 全部 API 路由(公开接口在中间件组外)
├── web.php 欢迎页
└── console.php 调度注册
```
使用方式:直接通过命名空间引用,如 `\response\Result::success()`、`\jwt\Jwt::getTokenFromHeader()`。
路由组织约定见 [3.7 路由注册](/zh/guide/route.html)。
## route/ 路由定义
## resources/templates/ 代码生成模板
```text
route/
└── app.php 应用路由定义(所有 API 路由)
```
路由按模块分组,示例:
```php
// 公开接口(无需认证)
Route::group('login', function () {
Route::post('login', 'LoginController/login');
Route::get('captcha', 'LoginController/captcha');
});
// 需认证接口(AuthMiddleware)
Route::group('article', function () {
Route::get('page', 'ArticleController/page');
Route::post('add', 'ArticleController/add');
Route::put('update', 'ArticleController/update');
Route::delete('delete/:id', 'ArticleController/delete');
})->middleware(AuthMiddleware::class);
```
## templates/ 代码生成模板
```text
templates/
resources/templates/
├── controller.php.tpl 控制器模板
├── logic.php.tpl 业务逻辑模板
├── service.php.tpl 服务层模板
├── model.php.tpl 模型模板
├── validate.php.tpl 验证器模板
├── ui/ 普通列表前端模板
├── request.php.tpl FormRequest 模板
├── ui/ 普通列表前端模板(6 个)
└── ui2/ 树形结构前端模板
```
由 `GeneratorService` 渲染,通过代码生成器 CLI 或 API 调用。
由 `GeneratorService` 渲染,通过 CLI(`generate:code`)或 Web 界面调用。
## 前端目录设计
@@ -211,28 +188,32 @@ ui/src/
├── views/ ← 页面视图(按业务域分组)
├── components/ ← 公共组件(跨页面复用)
├── store/ ← 状态管理(Pinia)
├── router/ ← 路由配置
├── router/ ← 路由配置(动态路由由后端菜单驱动)
├── hooks/ ← 组合式函数
├── directives/ ← 自定义指令
├── directives/ ← 自定义指令(v-perm 权限)
├── enums/ ← 枚举常量
├── styles/ ← 全局样式
├── utils/ ← 工具函数
├── utils/ ← 工具函数(http 封装)
├── layout/ ← 布局组件
├── plugins/ ← 插件
├── settings/ ← 项目配置
└── assets/ ← 静态资源
```
:::tip
前端工程位于本仓库 `ui/` 目录,代码生成器输出至 `ui/src/` 对应位置。详细结构见 [9.2 目录结构详解](/zh/frontend/directory.html)。
:::
## 前后端目录对应关系
| 后端 | 前端 | 说明 |
|------|------|------|
| `app/controller/UserController.php` | `src/api/system/user.ts` | API 接口函数 |
| `app/logic/UserLogic.php` | `src/views/system/user/` | 业务页面 |
| `app/model/User.php` | — | 前端不直接操作模型 |
| `app/validate/UserValidate.php` | — | 前端校验由表单组件处理 |
| `route/app.php` | `src/api/**/*.ts` | 路由 ↔ API 函数映射 |
| `config/dict*.php` | `src/enums/` | 字典 ↔ 枚举常量 |
| `Http/Controllers/Api/UserController.php` | `ui/src/api/system/user.ts` | API 接口函数 |
| `Services/UserService.php` | `src/views/system/user/` | 业务页面 |
| `Models/User.php` | — | 前端不直接操作模型 |
| `Http/Requests/UserRequest.php` | 表单组件校验 | 前端校验为体验层,后端 FormRequest 为最终防线 |
| `routes/api.php` | `ui/src/api/**/*.ts` | 路由 ↔ API 函数映射 |
| 字典数据(`dict` 表) | `src/enums/` | 字典 ↔ 枚举常量 |
## 设计原则
@@ -242,29 +223,29 @@ ui/src/
| 层 | 职责 | 不应该做 |
|----|------|----------|
| Controller | 参数获取、响应封装、注解标注 | 业务逻辑、数据库操作 |
| Logic | 业务编排、钩子处理、校验 | 直接 HTTP 交互 |
| Model | 表映射、关联关系、软删除 | 复杂业务判断 |
| Validate | 参数格式校验 | 业务规则校验 |
| Controller | 参数注入、响应封装、注解标注 | 业务逻辑、数据库操作 |
| Request | 参数**格式**校验 | 业务规则校验 |
| Service | 业务编排、钩子处理、业务规则校验 | 直接 HTTP 交互 |
| Model | 表映射、关联关系、软删除、审计 | 复杂业务判断 |
### 按模块分组
同一业务模块的 4 个文件(Controller / Logic / Model / Validate)分散在各自目录中,通过命名前缀关联。这种"水平分层 + 垂直命名"的方式:
同一业务模块的 4 个文件分散在各自目录中,通过命名前缀关联。这种"水平分层 + 垂直命名"的方式:
- 便于按职责查找(所有控制器在一起)
- 便于按职责查找(所有服务在一起)
- 便于代码生成器按模板批量生成
- 便于基类统一处理(如 BaseLogic 的属性驱动)
- 便于基类统一处理(BaseService 属性驱动 + Trait 占位覆盖)
### 文件命名规范
| 类型 | 规范 | 示例 |
|------|------|------|
| 控制器 | 大驼峰 + Controller | `ArticleController.php` |
| 逻辑层 | 大驼峰 + Logic | `ArticleLogic.php` |
| 请求 | 大驼峰 + Request | `ArticleRequest.php` |
| 服务 | 大驼峰 + Service | `ArticleService.php` |
| 模型 | 大驼峰(表名) | `Article.php` |
| 验证器 | 大驼峰 + Validate | `ArticleValidate.php` |
| 服务 | 大驼峰 + Service | `DictService.php` |
| 中间件 | 大驼峰 + Middleware | `AuthMiddleware.php` |
| 注解 | 大驼峰 | `Log.php`、`Permission.php` |
| 命令 | 大驼峰 + Command | `GeneratorCommand.php` |
| 中间件 | 大驼峰(动词+名词) | `Authenticate.php` / `ResolveTenant.php` |
| 注解 | 大驼峰 | `Permission.php`、`OperationLog.php` |
| 命令 | 大驼峰 + Command(或动词开头) | `GeneratorCommand.php`、`ClearDictCache.php` |
| 任务 | 大驼峰 + Task | `SendSmsTask.php` |
| Trait | 动词开头 + Has/By | `HasCrudOperations.php`、`SoftDeletesByFlag.php` |
@@ -1,311 +1,189 @@
# 4.11 扩展性设计
:::tip 概述
系统通过六种机制保证良好的扩展性:属性配置、生命周期钩子、自动参数校验、中间件链、注解系统、服务层复用。新增模块无需修改框架代码,只需继承基类 + 配置属性 + 按需重写钩子。
系统通过六种机制保证良好的扩展性:属性配置、生命周期钩子、FormRequest 参数校验、中间件链、注解系统、服务层复用。新增模块无需修改框架代码,只需继承基类 + 配置属性 + 按需重写钩子。
:::
## 扩展机制总览
| 机制 | 位置 | 作用 | 扩展方式 |
|------|------|------|----------|
| 属性配置 | Logic 子类 | 声明式行为定义 | 设置属性值 |
| 生命周期钩子 | Logic 子类 | 注入定制逻辑 | 重写钩子方法 |
| 自动参数校验 | Validate + Logic | 参数格式校验 | 创建 Validate + 配置 `$validateClass` |
| 中间件链 | middleware/ | 请求拦截处理 | 创建中间件 + 注册 |
| 注解系统 | attribute/ | 声明式元数据 | 创建注解 + 中间件读取 |
| 服务层复用 | service/ | 跨模块通用能力 | 创建 Service 类 |
| 属性配置 | Service 子类 | 声明式行为定义 | 设置属性值 |
| 生命周期钩子 | Service 子类 | 注入定制逻辑 | 重写钩子方法 |
| 参数格式校验 | FormRequest | HTTP 入口拦截非法输入 | 创建 Request + 类型注入 |
| 中间件链 | `Http/Middleware/` | 请求拦截处理 | 创建中间件 + 注册别名 |
| 注解系统 | `Attributes/` | 声明式元数据 | 创建注解 + 中间件反射读取 |
| 服务层复用 | `Services/` | 跨模块通用能力 | 创建 Service 类 |
## 1. 属性配置模式
Logic 层通过属性配置声明行为,新增模块只需设置属性,无需编写 CRUD 代码。
Service 层通过属性配置声明行为,新增模块只需设置属性,无需编写 CRUD 代码。
### 完整属性清单
### 完整属性清单(摘自 `app/Services/ArticleService.php` 实际代码)
```php
class NewModuleLogic extends BaseLogic
class ArticleService extends BaseTenantService
{
/**
* 关联的模型类
*
* 所有增删改查操作都会基于此模型执行,子类必须指定
*
* @var string
*/
protected string $modelClass = NewModule::class;
use HasCrudOperations, HasFileProcessing, HasQueryScopes, HasSerializeMaps;
// 数据权限能力按需 use HasDataScope
/**
* 参数验证器类
*
* 配置后,新增 / 修改等操作会自动调用该验证器进行参数校验,
* 校验不通过会直接抛出异常,无需在业务代码中手动验证
*
* @var string
* 绑定的模型类
*/
protected string $validateClass = NewModuleValidate::class;
protected string $modelClass = Article::class;
/**
* 需要自动处理的文件上传字段
* 单文件字段:保存时迁移临时文件,读取时补全域名
*
* 提交数据中若包含这些字段,框架会自动完成文件上传、存储与路径回填
*
* @var array
* @var array<int, string>
*/
protected array $fileFields = ['cover', 'avatar'];
protected array $fileFields = ['cover'];
/**
* 文件保存的子目录
* 多文件字段:保存时逐个迁移,读取时逐个补全域名
*
* 上传的文件会保存在该子目录下(相对于文件存储根目录)
*
* @var string
* @var array<int, string>
*/
protected string $fileSaveDir = 'new_module';
protected array $multiFileFields = ['images'];
/**
* 富文本字段
*
* 这些字段会做富文本特殊处理(如 XSS 过滤、图片路径修正等)
*
* @var array
* 文件保存子目录
*/
protected array $contentFields = ['content'];
protected string $fileSaveDir = 'article';
/**
* 列表查询中需要 LIKE 模糊匹配的字段
* 分页时模糊匹配的字段
*
* 请求参数中若带有这些字段,会自动以 LIKE '%value%' 的方式参与查询
*
* @var array
* @var array<int, string>
*/
protected array $pageLikeFields = ['name', 'title'];
protected array $pageLikeFields = ['title', 'author'];
/**
* 列表查询中需要精确匹配的字段
* 分页时精确匹配的字段
*
* 请求参数中若带有这些字段,会自动以 = 的方式参与查询
*
* @var array
* @var array<int, string>
*/
protected array $pageEqFields = ['status', 'type'];
protected array $pageEqFields = ['category_id', 'status'];
/**
* 列表查询的默认排序规则
* 默认排序规则(多字段)
*
* field:排序字段;type:排序方式(asc / desc)
*
* @var array
* @var array<int, array<string, string>>
*/
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
protected array $pageOrderBy = [
['field' => 'tenant_id', 'type' => 'asc'],
['field' => 'sort', 'type' => 'asc'],
];
/**
* 唯一性校验字段
* 字典翻译配置:[字段 => 字典编码]
*
* 支持两种写法:
* - 字符串:单字段全局唯一,如 'code'
* - 数组:多字段组合唯一,如 ['name', 'pid'] 表示同层级下 name 唯一
*
* 新增 / 修改时会自动校验,冲突则抛出异常
*
* @var array
* @var array<string, string>
*/
protected array $uniqueFields = ['code', ['name', 'pid']];
protected array $serializeMaps = [
'status' => 'article_status',
];
/**
* 枚举字段与显示名的映射
*
* 键为数据表字段名,值为对应的枚举字典 key,
* 返回数据时会自动附加对应的枚举显示名(如 status_text)
*
* @var array
*/
protected array $serializeMaps = ['status' => 'module_status'];
/**
* 数据权限-按创建人过滤的字段
*
* 配置后,非管理员用户只能看到自己创建的数据
*
* @var string
* 数据权限的用户字段(data_scope=3 仅本人时按此字段过滤)
*/
protected string $dataScopeUserField = 'create_user';
/**
* 数据权限-按部门过滤的字段
*
* 配置后,非管理员用户只能看到本部门(及子部门)的数据
*
* @var string
*/
protected string $dataScopeDeptField = 'dept_id';
/**
* 租户隔离字段
*
* 继承 BaseTenantLogic 时自动启用,所有查询会自动追加 tenant_id 条件,
* 保证多租户之间的数据完全隔离
*
* @var string
*/
// protected string $tenantScopeField = 'tenant_id';
/**
* 树形结构的父级字段
*
* 配置后,列表接口会自动将平铺数据组装成树形结构
*
* @var string
*/
protected string $treeParentField = 'parent_id';
/**
* 树形结构的搜索字段
*
* 树形搜索时对该字段做模糊匹配,命中节点会自动保留其父链
*
* @var string
*/
protected string $treeLikeField = 'name';
}
```
其它可声明属性(`uniqueFields`、`contentFields`、`dataScopeDeptField`、`tenantScopeField`、`treeParentField` / `treeLikeField` 等)均在 `BaseService` 中声明,完整清单见 [4.6 核心基类体系](/zh/architecture/base-classes.html)。
### 属性驱动的自动行为
| 属性 | 触发的自动行为 |
|------|---------------|
| `validateClass` | add/update 时自动调用验证器校验参数 |
| `fileFields` | add/update 时迁移临时文件,查询时补全域名 |
| `multiFileFields` | 逗号分隔多文件逐个迁移/补全 |
| `contentFields` | add/update 时处理富文本媒体文件,查询时替换占位符 |
| `pageLikeFields` | pageList 时自动构建 LIKE 查询 |
| `pageEqFields` | pageList 时自动构建精确查询 |
| `pageOrderBy` | pageList/allList 时自动应用默认排序 |
| `uniqueFields` | add/update 时自动校验唯一性 |
| `serializeMaps` | 查询时自动补全 `{字段名}Text` 枚举显示名 |
| `dataScopeUserField` | 查询时自动按创建人过滤 |
| `dataScopeDeptField` | 查询时自动按部门过滤 |
| `tenantScopeField` | 查询时自动叠加 tenant_id 条件,新增时自动填充 |
| `pageOrderBy` / `orderFieldAllow` | 自动应用默认排序 / 动态排序白名单 |
| `uniqueFields` | add/update 时自动校验唯一性(BizException 拦截) |
| `serializeMaps` | 查询时自动补全 `{字段}Text` 字典显示名 |
| `dataScopeUserField` / `dataScopeDeptField` | 查询时自动按创建人/部门过滤 |
| `tenantScopeField` | 查询时自动叠加 `tenant_id` 条件,新增时自动填充 |
| `treeParentField` / `treeLikeField` | 树形列表自动组装 / 子树模糊搜索 |
:::tip 未启用的属性是空操作
`BaseService` 为 6 个 Trait 的方法提供占位实现,子类 `use` 对应 Trait 后真实实现才生效(占位覆盖机制)。属性声明了但未 `use` 对应 Trait 时,对应自动行为不会执行。
:::
## 2. 生命周期钩子
通过重写钩子注入定制逻辑,无需修改基类。钩子方法在基类中定义为空实现或默认返回,子类按需覆盖。
通过重写钩子注入定制逻辑,无需修改基类。钩子方法在 `HasCrudOperations` 中定义为空实现或默认返回,子类按需覆盖。
### 钩子清单
| 钩子 | 时机 | 参数 | 返回值 | 可拦截 |
|------|------|------|--------|--------|
| `beforeAdd` | 新增前 | `$data` | 处理后的 `$data` | ✅ 抛异常 |
| `afterAdd` | 新增后 | `$id, $data` | void | ❌ |
| `afterAdd` | 新增后 | `$id, $data` | void(事务内) | ✅ 抛异常回滚 |
| `beforeUpdate` | 修改前 | `$id, $data` | 处理后的 `$data` | ✅ 抛异常 |
| `afterUpdate` | 修改后 | `$id, $data` | void | ❌ |
| `afterUpdate` | 修改后 | `$id, $data` | void(事务内) | ✅ 抛异常回滚 |
| `beforeDelete` | 删除前 | `$id` | void | ✅ 抛异常 |
| `afterDelete` | 删除后 | `$id` | void | ❌ |
| `afterDelete` | 删除后 | `$id` | void(事务内) | ✅ 抛异常回滚 |
| `beforeBatchDelete` | 批量删除前 | `$ids` | void | ✅ 抛异常 |
| `afterBatchDelete` | 批量删除后 | `$ids` | void | ❌ |
| `afterBatchDelete` | 批量删除后 | `$ids` | void(事务内) | ✅ 抛异常回滚 |
| `afterDetail` | 详情查询后 | `$id, &$data` | void(引用传递) | ❌ |
| `afterPageList` | 列表查询后 | `&$records, $params` | void(引用传递,分页与全量列表均触发) | ❌ |
### 典型用法
### 典型用法(摘自项目实际代码)
```php
class ArticleLogic extends BaseTenantLogic
// app/Services/DeptService.php —— 删除前校验(抛 BizException 拦截删除并回滚)
protected function beforeDelete(int $id): void
{
/**
* 新增前处理:设置默认值
*
* @param array $data 待新增的数据
* @return array 处理后的数据
*/
protected function beforeAdd(array $data): array
{
// sort:排序字段,若未传则默认为 0
$data['sort'] = $data['sort'] ?? 0;
// author:作者字段,若未传则尝试从当前登录用户信息中取用户名
$data['author'] = $data['author'] ?? request()->userInfo->username ?? '';
return $data;
$hasChildren = Dept::where('parent_id', $id)->exists();
if ($hasChildren) {
throw new BizException('存在子部门,不可删除');
}
/**
* 新增后处理:保存关联数据
*
* @param int $id 新增文章的主键 ID
* @param array $data 新增时提交的原始数据
* @return void
*/
protected function afterAdd(int $id, array $data): void
{
// 若提交数据中带有 tags(标签数组),则批量写入文章-标签关联表
if (!empty($data['tags'])) {
ArticleTag::insertAll(array_map(fn($tag) => [
'article_id' => $id, 'tag' => $tag
], $data['tags']));
}
$hasUsers = User::where('dept_id', $id)->exists();
if ($hasUsers) {
throw new BizException('该部门下存在用户,不可删除');
}
}
// app/Services/ArticleService.php —— 详情后处理(引用传递,补充关联数据)
protected function afterDetail(int $id, array &$data): void
{
$images = $data['images'] ?? '';
$data['imagesList'] = ! empty($images)
? array_map('trim', explode(',', $images))
: [];
}
// app/Services/UserService.php —— 列表后处理(批量补充,避免 N+1)
protected function afterPageList(array &$records, array $params): void
{
$userIds = array_column($records, 'id');
if (empty($userIds)) {
return;
}
/**
* 修改前处理:过滤不可修改字段
*
* @param int $id 文章 ID
* @param array $data 待更新的数据
* @return array 过滤后的数据
*/
protected function beforeUpdate(int $id, array $data): array
{
// author(作者)字段不允许通过更新接口修改,直接剔除
unset($data['author']);
return $data;
// 批量获取用户角色(仅查所需字段)
$userRoles = [];
$roleData = UserRole::whereIn('user_id', $userIds)->select('user_id', 'role_id')->get();
foreach ($roleData as $rd) {
$userRoles[$rd->user_id][] = $rd->role_id;
}
/**
* 删除前处理:业务约束校验
*
* @param int $id 文章 ID
* @return void
* @throws \Exception 文章下存在评论时抛出
*/
protected function beforeDelete(int $id): void
{
// 若该文章下存在评论,则不允许删除
$commentCount = Comment::where('article_id', $id)->count();
if ($commentCount > 0) {
throw new \Exception('该文章下存在评论,不可删除');
}
}
/**
* 详情查询后处理:补充单条记录的关联数据
*
* @param int $id 文章 ID
* @param array &$data 详情数据(引用传递)
* @return void
*/
protected function afterDetail(int $id, array &$data): void
{
$data['tags'] = ArticleTag::where('article_id', $id)->column('tag');
$data['commentCount'] = Comment::where('article_id', $id)->count();
}
/**
* 列表查询后处理:批量补充关联数据
*
* @param array &$records 记录列表(引用传递)
* @param array $params 查询参数
* @return void
*/
protected function afterPageList(array &$records, array $params): void
{
if (empty($records)) return;
// 提取当前页所有文章 ID
$ids = array_column($records, 'id');
// 一次性统计每篇文章的评论数,返回 [article_id => count] 结构
$counts = Comment::whereIn('article_id', $ids)
->group('article_id')
->column('count(*)', 'article_id');
// 将统计结果回填到每条记录
foreach ($records as &$record) {
$record['commentCount'] = $counts[$record['id']] ?? 0;
}
$roleMap = Role::getNameMap();
$deptMap = Dept::getNameMap();
foreach ($records as &$item) {
// 补充角色名称,组装为 [{id, name}]
$roleIds = $userRoles[$item['id']] ?? [];
$item['roles'] = array_map(
fn ($roleId) => ['id' => $roleId, 'name' => $roleMap[$roleId] ?? ''],
$roleIds
);
$item['deptName'] = $deptMap[$item['dept_id'] ?? 0] ?? '';
unset($item['password'], $item['salt']);
}
}
```
@@ -313,51 +191,72 @@ class ArticleLogic extends BaseTenantLogic
### 执行顺序
**add 流程:**
`beforeAdd` → `validateData` → `camel_to_snake` → `processTenantId` → `processFile` → `processContent` → `filterTableFields` → `checkUnique` → `Model::create` → `afterAdd`
`beforeAdd` → `checkUnique` → `DB::transaction { processTenantId → processFile → processMultiFile → processContent → Model::create(审计填充)→ afterAdd }`
**update 流程:**
`beforeUpdate` → `validateData` → `camel_to_snake` → `processFile` → `processContent` → `filterTableFields` → `checkUnique` → `Model::find + save` → `afterUpdate`
`beforeUpdate` → `checkUnique(excludeId)` → `DB::transaction { 同上处理 → update(审计刷新)→ afterUpdate }`
**delete 流程:**
`beforeDelete` → `is_delete = 1` → `afterDelete`
`beforeDelete` → `DB::transaction { is_delete = 1 → afterDelete }`
**pageList/allList 流程:**
`buildQuery` → `排序` → `分页` → `processFile` → `processContent` → `processSerializeMaps` → `filterFields` → `afterPageList`
`buildQuery` → `排序` → `分页/全量` → `processAfterRead(文件/字典翻译/字段过滤)` → `afterPageList`
## 3. 自动参数校验
(参数格式校验在入口 FormRequest 完成,不在此流程内。)
通过在 Logic 中配置 `$validateClass`,add/update 时自动调用验证器,无需手动调用。
## 3. 参数格式校验(FormRequest)
### 扩展方式
通过控制器方法类型注入 FormRequest,进入方法前自动校验,无需手动调用。
### 扩展方式(摘自 `app/Http/Requests/ExampleRequest.php` 实际代码)
```php
// 1. 创建验证器
// app/validate/NewModuleValidate.php
class NewModuleValidate extends Validate
// 1. 创建请求类
// app/Http/Requests/ExampleRequest.php
class ExampleRequest extends BaseRequest
{
protected $rule = [
'name' => 'require|max:100',
'code' => 'require|unique:new_module',
];
protected $message = [
'name.require' => '名称不能为空',
'code.unique' => '编码已存在',
];
protected $scene = [
'add' => ['name', 'code'],
'update' => ['name'],
];
/**
* 校验规则
*
* @return array<string, string> 字段规则
*/
public function rules(): array
{
return [
'name' => 'required|string|max:255', // 案例名称:必填,最长 255
'avatar' => 'nullable|string|max:255', // 头像/图片地址:可空,最长 255
'type' => 'nullable|integer|in:1,2,3,4', // 类型:1=类型1,2=类型2,3=类型3,4=类型4
'status' => 'nullable|integer|in:1,2', // 状态:1=在用,2=停用
'sort' => 'nullable|integer', // 排序值:可空,整数
];
}
/**
* 自定义错误消息
*
* @return array<string, string> 字段规则 => 错误消息
*/
public function messages(): array
{
return [
'name.required' => '案例名称不能为空',
'name.max' => '案例名称不能超过 255 个字符',
'avatar.max' => '图片地址不能超过 255 个字符',
'type.in' => '案例类型不合法,允许:1-类型1 2-类型2 3-类型3 4-类型4',
'status.in' => '状态值不合法,允许:1-在用 2-停用',
'sort.integer' => '排序值格式不正确',
];
}
}
// 2. 在 Logic 中配置
class NewModuleLogic extends BaseLogic
// 2. 控制器类型注入(校验通过才进入方法)
public function add(ExampleRequest $request): JsonResponse
{
protected string $validateClass = NewModuleValidate::class;
// ...
return $this->_add($this->exampleService, $request->validated());
}
```
详见 [3.4 验证器开发](/zh/guide/validator.html)。
详见 [3.4 FormRequest 验证器](/zh/guide/validator.html)。
## 4. 中间件机制
@@ -369,68 +268,95 @@ class NewModuleLogic extends BaseLogic
请求进入
│
▼
CorsMiddleware(全局) ← OPTIONS 预检 + 跨域响应头
ProxyMiddleware(API 组前置) ← 受信代理真实 IP 还原
│
▼
AuthMiddleware(路由级) ← JWT 认证 + 权限校验 → 401/403
│ 注入 userInfo
▼
TenantMiddleware(路由级) ← 租户上下文注入 → 403
│ 注入 tenantId
▼
DemoMiddleware(路由级) ← 演示环境拦截写操作 → 403
Cors(API 组前置) ← OPTIONS 预检 + 跨域响应头
│
▼
LogMiddleware(路由级) ← 操作日志记录
ForceJsonResponse(API 组前置) ← Accept 强制 application/json
│
▼
Authenticate(路由级,别名 auth.jwt) ← JWT 认证 + #[Permission] 权限校验 → 401/403
│ 注入 attributes['userInfo']
▼
ResolveTenant(路由级,别名 tenant) ← 租户上下文注入 → 403
│ 注入 attributes['tenantId']
▼
LogOperation(路由级,别名 log.operation) ← 操作日志采集(terminate 落库)
│
▼
控制器方法
(另有 DemoMiddleware,已注册 demo 别名,可挂载到路由组)
```
### 创建自定义中间件
中间件结构参考(摘自 `app/Http/Middleware/DemoMiddleware.php` 实际代码):
```php
// 1. 创建中间件类
// app/middleware/RateLimitMiddleware.php
namespace app\middleware;
namespace App\Http\Middleware;
use think\facade\Cache;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RateLimitMiddleware
class DemoMiddleware
{
public function handle($request, \Closure $next)
public function handle(Request $request, Closure $next): Response
{
$ip = $request->ip();
$key = 'rate_limit_' . $ip;
$count = Cache::get($key, 0);
if ($count > 100) {
return json(['code' => 1, 'msg' => '请求过于频繁'], 429);
// 非演示模式直接放行
if (! config('app.demo_mode', false)) {
return $next($request);
}
Cache::set($key, $count + 1, 60);
return $next($request);
// GET 请求放行(读操作不受限)
if ($request->isMethod('GET')) {
return $next($request);
}
// 检查是否有 #[DemoAllow] 注解(反射读取,存在即放行)
$route = $request->route();
if ($route) {
$reflection = new \ReflectionMethod(
$route->getControllerClass(),
$route->getActionMethod()
);
if (! empty($reflection->getAttributes(DemoAllow::class))) {
return $next($request);
}
}
// 演示模式下拦截写操作
return response()->json([
'code' => 403,
'ok' => false,
'msg' => '演示模式下禁止操作',
'data' => null,
], 403);
}
}
```
// 2. 注册中间件
// 方式一:全局注册(app/middleware.php)
return [
\app\middleware\RateLimitMiddleware::class,
];
注册方式(`bootstrap/app.php` 中的三种挂载点):
// 方式二:路由级注册(route/app.php)
Route::group('api', function () {
// ...
})->middleware(\app\middleware\RateLimitMiddleware::class);
```php
// 方式一:注册别名(现有 7 个别名即在此处)
$middleware->alias([
'proxy' => ProxyMiddleware::class,
'cors' => Cors::class,
'force.json' => ForceJsonResponse::class,
'auth.jwt' => Authenticate::class,
'tenant' => ResolveTenant::class,
'demo' => DemoMiddleware::class,
'log.operation' => LogOperation::class,
]);
// 方式三:控制器级注册
class SomeController extends BaseController
{
protected $middleware = [
\app\middleware\RateLimitMiddleware::class,
];
}
// 方式二:API 组前置(对全部 /api 请求生效)
$middleware->api(prepend: [ProxyMiddleware::class, Cors::class, ForceJsonResponse::class]);
// 方式三:路由组注册(routes/api.php)
Route::middleware(['auth.jwt', 'tenant', 'demo', 'log.operation'])->group(function () { /* ... */ });
```
## 5. 注解系统
@@ -441,15 +367,15 @@ class SomeController extends BaseController
| 注解 | 目标 | 说明 | 读取方 |
|------|------|------|--------|
| `#[Log]` | 方法 | 操作日志(标题、类型、描述) | LogMiddleware |
| `#[Permission]` | 方法/类 | 权限校验(权限编码) | AuthMiddleware |
| `#[DemoAllow]` | 方法 | 演示环境放行标记 | DemoMiddleware |
| `#[OperationLog]` | 方法 | 操作日志(标题、类型、描述模板) | `LogOperation` 中间件(ReflectionMethod) |
| `#[Permission]` | 方法 | 权限校验(权限编码、名称) | `Authenticate` 中间件(ReflectionMethod) |
| `#[DemoAllow]` | 方法 | 演示环境放行标记 | `DemoMiddleware`(ReflectionMethod) |
### 使用示例
```php
// 操作日志
#[Log('用户管理-新增记录', Log::TYPE_ADD, '新增用户:{username}')]
// 操作日志(TYPE 常量 30 种,描述支持 {param} 占位符)
#[OperationLog('用户管理-新增', OperationLog::TYPE_ADD, '新增用户:{username}')]
// 权限校验
#[Permission('sys:user:add', '添加用户')]
@@ -458,54 +384,50 @@ class SomeController extends BaseController
#[DemoAllow]
// 组合使用
#[Log('文章管理-删除记录', Log::TYPE_DELETE, '删除文章ID:{id}')]
#[OperationLog('文章管理-删除', OperationLog::TYPE_DELETE, '删除文章ID:{id}')]
#[Permission('sys:article:delete', '删除文章')]
public function delete(int $id): Json
public function delete(int $id): JsonResponse
{
return parent::_remove($this->logic, $id);
return $this->_delete($this->service, $id);
}
```
### 创建自定义注解
### 注解结构与读取方式(摘自项目实际代码)
```php
// 1. 定义注解类
// app/attribute/Cacheable.php
namespace app\attribute;
use Attribute;
// 1. 定义注解类(app/Attributes/Permission.php 实际代码)
#[Attribute(Attribute::TARGET_METHOD)]
class Cacheable
class Permission
{
/**
* @param string $code 权限编码(鉴权唯一依据,如 sys:user:add)
* @param string $name 权限名称(用于展示,如"添加用户")
* @param string $description 权限描述(可选,补充说明)
*/
public function __construct(
public string $key = '',
public int $ttl = 3600,
public string $code,
public string $name = '',
public string $description = '',
) {}
}
// 2. 在控制器方法上使用
#[Cacheable(key: 'article_list', ttl: 1800)]
public function list(): Json { ... }
#[Permission('sys:user:add', '添加用户')]
public function add(UserRequest $request): JsonResponse { ... }
// 3. 在中间件中读取
// 通过 AttributeService 或反射获取注解实例
$ref = new \ReflectionMethod($controller, $method);
$attrs = $ref->getAttributes(Cacheable::class);
if (!empty($attrs)) {
$cacheable = $attrs[0]->newInstance();
// $cacheable->key, $cacheable->ttl
// 3. 在中间件中读取(DemoMiddleware 实际代码同款反射方式)
$reflection = new \ReflectionMethod($controllerClass, $method);
$attrs = $reflection->getAttributes(DemoAllow::class);
if (! empty($attrs)) {
return $next($request); // 命中注解即放行
}
```
### 注解读取服务
新增自定义注解时,按同样结构在 `app/Attributes/` 下定义类,再在中间件中用 `ReflectionMethod::getAttributes()` 读取即可。
`AttributeService` 提供统一的注解读取接口(带缓存):
```php
AttributeService::getLog($class, $method); // 获取 Log 注解
AttributeService::getPermission($class, $method); // 获取 Permission 注解
```
:::tip 注解读取性能
反射读取开销为微秒级,`Authenticate` / `LogOperation` 均直接使用 `ReflectionMethod` 读取(无缓存),避免开发阶段改注解后需等缓存过期。
:::
## 6. 服务层复用
@@ -513,100 +435,124 @@ AttributeService::getPermission($class, $method); // 获取 Permission 注解
### 现有服务清单
| 服务 | 文件 | 能力 |
| 服务 | 文件(`app/Services/`) | 能力 |
|------|------|------|
| `JwtService` | `app/service/JwtService.php` | 登录、刷新、解析 Token |
| `PasswordService` | `app/service/PasswordService.php` | bcrypt 双重加盐加密 |
| `DictService` | `app/service/DictService.php` | 字典查询(两级缓存) |
| `ParamService` | `app/service/ParamService.php` | 系统参数查询(两级缓存) |
| `CaptchaService` | `app/service/CaptchaService.php` | 图片验证码生成与校验 |
| `RequestInfoService` | `app/service/RequestInfoService.php` | 客户端 IP、UA、归属地解析 |
| `AttributeService` | `app/service/AttributeService.php` | 注解反射读取 + 缓存 |
| `GeneratorService` | `app/service/GeneratorService.php` | 代码生成 |
| `ExcelService` | `app/service/ExcelService.php` | Excel 导入导出 |
| `DbMigrateService` | `app/service/DbMigrateService.php` | 跨数据库迁移引擎 |
| `DbSchemaBuilder` | `app/service/DbSchemaBuilder.php` | DDL 生成器(跨库映射) |
| `JwtService` | `JwtService.php` | 登录、刷新、解析 Token(Redis 有状态) |
| `PasswordService` | `PasswordService.php` | bcrypt 双重加盐加密 |
| `DictCacheService` | `DictCacheService.php` | 字典查询(两级缓存) |
| `CaptchaService` | `CaptchaService.php` | 图片验证码生成与校验(IP 限流) |
| `RequestInfoService` | `RequestInfoService.php` | 客户端 IP、UA、归属地解析 |
| `GeneratorService` | `GeneratorService.php` | 代码生成 |
| `ExcelService` | `ExcelService.php` | Excel 导入导出 |
| `DbMigrateService` | `DbMigrateService.php` | 跨数据库迁移引擎 |
| `DbSchemaBuilder` | `DbSchemaBuilder.php` | DDL 生成器(跨库映射、Oracle 序列) |
| `ConfigService` | `ConfigService.php` | 系统配置读写与缓存 |
| `DictService` | `DictService.php` | 字典/字典项管理 |
### 使用示例
```php
// 字典服务
DictService::getText('gender', 1); // → '男'
DictService::getValue('user_status', '启用'); // → 1
DictService::getOptions('gender'); // → [['value'=>0,'label'=>'女'], ...]
// 字典服务(静态调用,内部两级缓存)
DictCacheService::getText('gender', 1); // → '男'
DictCacheService::getValue('user_status', '启用'); // → 1
// 密码服务
$encrypted = PasswordService::encrypt('123456'); // → ['password'=>'...', 'salt'=>'...']
$default = PasswordService::getDefaultPasswordPlain(); // → '123456'
// Excel 服务
ExcelService::importWithMap($filePath, $headerMap);
ExcelService::chunkExport($headers, $callback, '导出.xlsx', 500);
ExcelService::export($list, $headers, '导出.xlsx');
// 请求信息
// 请求信息(IP 经 ProxyMiddleware 还原真实值)
$requestInfo = RequestInfoService::create(request());
$ip = $requestInfo->getIp(); // 客户端 IP(经 ProxyMiddleware 还原真实 IP)
$location = $requestInfo->getIpLocation(); // IP 归属地(基于 zoujingli/ip2region)
$os = $requestInfo->getOs(); // 操作系统
$browser = $requestInfo->getBrowser(); // 浏览器
$location = $requestInfo->getIpLocation(); // 基于 zoujingli/ip2region
```
### 创建自定义服务
```php
// app/service/NotificationService.php
namespace app\service;
服务结构参考(摘自 `app/Services/ParamService.php` 实际代码——无状态静态方法 + 请求级缓存的典型写法):
class NotificationService
```php
namespace App\Services;
use App\Models\Param;
/**
* 系统参数服务
*
* 从 param 表获取系统参数配置。
* 数据量小,不使用持久缓存,仅保留请求级缓存避免同请求内重复查库。
*/
class ParamService
{
// 发送站内通知
public static function send(int $userId, string $title, string $content): void
/**
* 请求级缓存:[参数编码 => 参数值]
*
* @var array<string, mixed>
*/
protected static array $cache = [];
/**
* 获取参数值
*
* 读取顺序:请求级缓存 → 查库。
*/
public static function get(string $code): mixed
{
\app\model\Notice::create([
'user_id' => $userId,
'title' => $title,
'content' => $content,
'status' => 0,
]);
if (array_key_exists($code, static::$cache)) {
return static::$cache[$code];
}
$value = /* 查库逻辑 */ null;
static::$cache[$code] = $value;
return $value;
}
// 批量发送
public static function batchSend(array $userIds, string $title, string $content): void
/**
* 清除缓存(参数变更后调用)
*/
public static function clearCache(string $code = ''): void
{
foreach ($userIds as $userId) {
self::send($userId, $title, $content);
if ($code !== '') {
unset(static::$cache[$code]);
return;
}
static::$cache = [];
}
}
// 使用
NotificationService::send($userId, '系统通知', '您有一条新消息');
```
新增服务时按此结构放在 `app/Services/` 下:无状态、单一职责、静态方法或构造注入均可;跨模块复用的能力才提升为 Service,业务专属逻辑留在业务 Service 中。
## 扩展性设计原则
### 开闭原则(OCP)
| 扩展场景 | 做法 | 不应该做 |
|----------|------|----------|
| 新增 CRUD 模块 | 创建 Controller/Logic/Model/Validate | 修改基类 |
| 新增查询条件 | 设置 `pageLikeFields`/`pageEqFields` | 重写 `buildQuery` |
| 新增文件处理 | 设置 `fileFields` | 手动调用 `save_file` |
| 新增校验规则 | 创建 Validate + 配置 `validateClass` | 在 Controller 手动 validate |
| 新增请求拦截 | 创建中间件 | 修改现有中间件 |
| 新增操作日志 | 添加 `#[Log]` 注解 | 在方法内手动记录日志 |
| 新增 CRUD 模块 | 创建 Request/Service/Model/Controller | 修改基类 |
| 新增查询条件 | 设置 `pageLikeFields` / `pageEqFields` | 重写 `buildQuery` |
| 新增文件处理 | `use HasFileProcessing` + 设置 `fileFields` | 手动搬运临时文件 |
| 新增校验规则 | 创建 FormRequest + 类型注入 | 在 Controller 手动校验 |
| 新增请求拦截 | 创建中间件 + 注册 | 修改现有中间件 |
| 新增操作日志 | 添加 `#[OperationLog]` 注解 | 在方法内手动记录日志 |
| 新增权限节点 | 添加 `#[Permission]` 注解 | 在方法内手动校验权限 |
| 新增通用能力 | 创建 Service | 在 Logic 中写静态方法 |
| 新增通用能力 | 创建 Service | 在业务 Service 里写静态方法 |
### 最小改动原则
新增一个完整的业务模块,只需要:
1. 建表(含公共字段)
2. 创建 Model(继承 BaseModel,声明表名)
3. 创建 Validate(定义规则 + 场景)
4. 创建 Logic(继承 BaseLogic/BaseTenantLogic,配置属性)
5. 创建 Controller(继承 BaseController,初始化 Logic,CRUD 一行调用)
6. 注册路由
2. 创建 Model(组合双 Trait,声明表名与 fillable)
3. 创建 FormRequest(定义规则与中文消息)
4. 创建 Service(继承 BaseService / BaseTenantService,配置属性 + 按需钩子)
5. 创建 Controller(构造注入 Service,CRUD 一行调用 + 双注解)
6. 注册路由(`routes/api.php` 认证组内)
7. 配置菜单权限(可选)
8. 前端 API + 页面(或交给代码生成器一次完成)
**不需要修改的文件:** 基类、中间件、配置文件、服务层。
**不需要修改的文件:** 基类、中间件、配置文件、横切服务。
(或直接使用 [第 10 章 代码生成器](/zh/generator/overview.html) 一键生成以上全部。)
@@ -1,14 +1,14 @@
# 4.4 前端分层架构
:::tip 概述
前端采用 Vue 3 + TypeScript 组件化架构,分为 View(视图)→ API(接口)→ Store(状态)→ HTTP(请求)四层。辅以 Router(路由)、Directives(指令)、Hooks(组合式函数)、Utils(工具)等支撑模块。
前端采用 Vue 3 + TypeScript 组件化架构,分为 View(视图)→ API(接口)→ Store(状态)→ HTTP(请求)四层。辅以 Router(路由)、Directives(指令)、Hooks(组合式函数)、Utils(工具)等支撑模块。前端为本仓库 `ui/` 目录内的 EleVue 管理后台。
:::
## 架构全景
```text
┌─────────────────────────────────────────────────────────────────────┐
│ 支撑模块 │
│ 支撑模块 │
│ │
│ Router(路由) Directives(指令) Hooks(组合式函数) │
│ src/router/ src/directives/ src/hooks/ │
@@ -19,7 +19,7 @@
│ 被主干层引用
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 主干链路 │
│ 主干链路 │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ View(视图层) │ │
@@ -40,10 +40,9 @@
│ │ Axios 封装:拦截器、Token 携带、错误处理 │ │
│ │ 文件:src/utils/http/axios/ │ │
│ └──────────────────────────┬──────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 后端 API(RESTful JSON) │ │
│ │ 后端 API(RESTful JSON,{code, ok, msg, data}) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
@@ -72,7 +71,7 @@ src/views/
├── content/ 内容管理
│ ├── article/ 文章管理
│ └── category/ 分类管理
├── login.vue 登录页
├── login/ 登录页
├── dashboard/ 仪表盘
└── ...
```
@@ -107,7 +106,7 @@ src/views/
</el-table-column>
</el-table>
<!-- 分页 -->
<!-- 分页(后端固定返回 records / total / current / size / pages) -->
<el-pagination v-model:current-page="pageNo" v-model:page-size="pageSize"
:total="total" @change="fetchData" />
</template>
@@ -145,44 +144,48 @@ onMounted(() => fetchData());
| 新增 | `{module}Add` | POST | `data` |
| 修改 | `{module}Update` | PUT | `data` |
| 删除 | `{module}Delete` | DELETE | URL 参数 |
| 批量删除 | `{module}BatchDelete` | DELETE | `data` |
| 批量删除 | `{module}BatchDelete` | **DELETE**(带 body) | `data`(`{ids:[]}`) |
| 导入 | `{module}Import` | POST | FormData |
| 导出 | `{module}Export` | GET | `params` |
:::warning 批量删除方法为 DELETE
本项目 `batchDelete` 路由为 **DELETE**(请求体仍传 `ids` 数组——DELETE 方法携带 body),见 [3.7 路由注册](/zh/guide/route.html)。
:::
**完整示例:**
```typescript
// src/api/system/user.ts
// src/api/tool/example.ts
import { http } from '@/utils/http/axios';
// 分页查询
export function getUserPage(params?) {
return http.request({ url: '/user/page', method: 'GET', params });
export function getExamplePage(params?) {
return http.request({ url: '/example/page', method: 'GET', params });
}
// 详情
export function getUserDetail(id) {
return http.request({ url: '/user/detail/' + id, method: 'GET' });
export function getExampleDetail(id) {
return http.request({ url: '/example/detail/' + id, method: 'GET' });
}
// 新增
export function userAdd(data: any) {
return http.request({ url: '/user/add', method: 'POST', data });
export function exampleAdd(data: any) {
return http.request({ url: '/example/add', method: 'POST', data });
}
// 修改
export function userUpdate(data: any) {
return http.request({ url: '/user/update', method: 'PUT', data });
export function exampleUpdate(data: any) {
return http.request({ url: '/example/update', method: 'PUT', data });
}
// 删除
export function userDelete(id) {
return http.request({ url: '/user/delete/' + id, method: 'DELETE' });
export function exampleDelete(id) {
return http.request({ url: '/example/delete/' + id, method: 'DELETE' });
}
// 批量删除
export function userBatchDelete(data: any) {
return http.request({ url: '/user/batchDelete', method: 'DELETE', data });
// 批量删除(DELETE + 请求体 ids 数组)
export function exampleBatchDelete(data: any) {
return http.request({ url: '/example/batchDelete', method: 'DELETE', data });
}
```
@@ -239,15 +242,18 @@ src/utils/http/axios/
```text
登录:
userStore.login(userInfo)
→ POST /login → 获取 access_token
→ POST /login(含 key/code 验证码参数)
→ 获取 access_token
→ Storage.set(ACCESS_TOKEN, token)
→ this.setToken(token)
获取用户信息:
userStore.GetInfo()
→ GET /index/getUserInfo
→ this.setPermissions(res.permissions)
→ this.setUserInfo(res)
→ GET /index/getUser
→ setPermissions(res.permissions)
→ setUserInfo(res)
获取菜单(动态路由):
→ GET /index/getMenus
登出:
userStore.logout()
@@ -280,7 +286,7 @@ src/utils/http/axios/
```text
1. 用户登录 → 获取 Token → 存入 Store
2. 路由守卫(router.beforeEach)检测到 Token
3. 调用 GET /index/getMenus 获取菜单树
3. 调用 GET /index/getMenus 获取菜单树(后端按用户权限过滤)
4. generator-routers.ts 将菜单树转换为路由表
5. router.addRoute() 动态注入路由
6. 渲染侧边栏菜单
@@ -324,6 +330,10 @@ src/utils/http/axios/
用户权限列表包含 `*:*:*` 时,表示超级管理员,拥有所有权限,`v-perm`/`v-perms` 指令始终放行。
:::
:::warning 前端权限不是安全防线
`v-perm` 只是体验层(隐藏按钮);真正的安全校验在后端 `#[Permission]` 注解 + `auth.jwt` 中间件(见 [3.8 权限节点配置](/zh/guide/permission.html))。
:::
## 支撑模块
### Hooks(组合式函数)
@@ -366,8 +376,8 @@ API: getExamplePage(params)
HTTP: 请求拦截器添加 Token
│ 发送 GET /example/page?pageNo=1&pageSize=20
▼
后端: Controller → Logic → Model → Database
│
后端: Controller → Service → Model → Database
│(响应 {code, ok, msg, data},snake→camel 已在后端完成)
▼
HTTP: 响应拦截器判断 code
│ code === 0 → 返回 data
@@ -387,8 +397,8 @@ Vue 响应式 → 表格自动刷新
登录成功
│
▼
userStore.setToken(token) → 响应式更新
userStore.setUserInfo(info) → 响应式更新
userStore.setToken(token) → 响应式更新
userStore.setUserInfo(info) → 响应式更新
userStore.setPermissions(perm) → 响应式更新
│
▼
@@ -402,14 +412,14 @@ v-perm 指令读取 permissions 列表 → 控制元素显隐
| 后端 | 前端 | 说明 |
|------|------|------|
| `app/controller/UserController.php` | `src/api/system/user.ts` | API 函数映射 |
| `app/logic/UserLogic.php` | `src/views/system/user/` | 业务页面 |
| `app/model/User.php` | — | 前端不直接操作模型 |
| `app/validate/UserValidate.php` | — | 前端校验由表单组件处理 |
| `app/validate/*Validate.php` 的场景 | 表单 `rules` | 前端表单校验规则 |
| `route/app.php` | `src/api/**/*.ts` | 路由 ↔ API 函数 |
| 后端字典 `DictService` | `src/enums/` + 字典 Store | 枚举常量 |
| 后端 `#[Permission]` 注解 | `v-perm` 指令 | 权限码对应 |
| `Http/Controllers/Api/UserController.php` | `src/api/system/user.ts` | API 函数映射 |
| `Services/UserService.php` | `src/views/system/user/` | 业务页面 |
| `Http/Requests/UserRequest.php` 的 rules | 表单 `rules` | 前端表单校验规则(体验层) |
| `Models/User.php` | — | 前端不直接操作模型 |
| `routes/api.php` | `src/api/**/*.ts` | 路由 ↔ API 函数 |
| 字典数据(`dict` 表) | `src/enums/` + 字典 Store | 枚举常量 |
| `#[Permission]` 注解 | `v-perm` 指令 | 权限码对应 |
| 分页 `records/total/current/size/pages` | el-pagination 绑定 | 固定分页结构 |
## 层间通信规范
@@ -15,43 +15,50 @@
▼
┌─────────────────────────────────────────────────────────────────┐
│ Nginx(反向代理) │
│ 静态资源托管 / SSL 终止 / 负载均衡 / gzip 压缩 │
│ 静态资源托管 / SSL 终止 / gzip 压缩 / 仅放行入口PHP │
└──────────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ ThinkPHP 6.1 Application │
│ Laravel 13 Application │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ 中间件链(按顺序执行) │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────┐ ┌───────┐ │ │
│ │ │ CORS │→│ Auth │→│ Tenant │→│ Demo │→│ Log │ │ │
│ │ │ 跨域处理 │ │JWT+权限 │ │ 租户上下文│ │演示拦截│ │操作日志│ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ └──────┘ └───────┘ │ │
│ │ 中间件链(按注册顺序执行) │ │
│ │ ┌───────┐ ┌──────┐ ┌───────────┐ ┌──────────┐ │ │
│ │ │ Proxy │→│ Cors │→│ForceJson │→│ auth.jwt │ │ │
│ │ │真实IP │ │ 跨域 │ │ 强制JSON │ │JWT+权限 │ │ │
│ │ └───────┘ └──────┘ └───────────┘ └──────────┘ │ │
│ │ 全局组(api prepend) 路由组 │ │
│ │ ┌────────┐ ┌─────────┐ │ │
│ │ │ tenant │→│log.oper.│ │ │
│ │ │租户上下文│ │操作日志 │ │ │
│ │ └────────┘ └─────────┘ │ │
│ │ 另有 DemoMiddleware(演示拦截,按路由别名启用) │ │
│ └───────────────────────────────┬───────────────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ 控制器层(Controller) │ │
│ │ BaseController → 统一响应 · 参数获取 · 通用 CRUD │ │
│ │ #[Permission] 权限注解 · #[Log] 日志注解 │ │
│ │ Controller 基类 → success/fail/paginate · _page/_add 等 │ │
│ │ #[Permission] 权限注解 · #[OperationLog] 日志注解 │ │
│ │ FormRequest 类型注入 → 参数格式校验 │ │
│ └───────────────────────────────┬───────────────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ 逻辑层(Logic) │ │
│ │ BaseLogic / BaseTenantLogic │ │
│ │ 属性配置 · 生命周期钩子 · 文件处理 · 枚举翻译 │ │
│ │ 唯一性校验 · 数据权限 · 租户隔离 · 事务管理 │ │
│ │ 服务层(Service) │ │
│ │ BaseService / BaseTenantService │ │
│ │ 属性配置 · 生命周期钩子 · 6 个 Concerns Trait 组合 │ │
│ │ 文件处理 · 字典翻译 · 唯一性校验 · 数据权限 · 租户隔离 │ │
│ │ DB::transaction 事务管理 · BizException 业务异常 │ │
│ └───────────────────────────────┬───────────────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ 模型层(Model) │ │
│ │ BaseModel │ │
│ │ 软删除(is_delete)· 审计字段自动写入 │ │
│ │ 模型层(Eloquent Model) │ │
│ │ SoftDeletesByFlag(is_delete 软删除)· HasAuditColumns │ │
│ └───────────────────────────────┬───────────────────────────┘ │
│ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ MySQL │ │ Redis │ │ 文件系统 │ │
│ │ 数据库 │ │ 缓存 │ │ uploads/ │ │
│ │ MySQL 等 │ │ Redis │ │ 文件系统 │ │
│ │ 五种数据库 │ │ 会话/缓存/队列│ │ public/uploads/ │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
@@ -64,7 +71,7 @@
### 1. 前后端分离
前端 Vue3 应用通过 HTTP API 调用后端 ThinkPHP 服务,两者独立开发、独立部署。前端开发阶段通过 Vite 的 `proxy` 配置将 `/api` 请求代理到后端 `http://localhost:8000`,生产环境由 Nginx 统一反向代理。
前端 Vue3 应用通过 HTTP API 调用后端 Laravel 服务,两者独立开发、独立部署。前端开发阶段通过 Vite 的 `proxy` 配置将 `/api` 请求代理到后端 `http://localhost:8000`,生产环境由 Nginx 统一反向代理。
### 2. 分层架构
@@ -72,92 +79,92 @@
| 层次 | 目录 | 职责 |
|------|------|------|
| 控制器层 | `app/controller/` | 路由入口、权限注解、参数获取、响应返回 |
| 逻辑层 | `app/logic/` | 业务逻辑、属性配置、生命周期钩子、横切处理 |
| 模型层 | `app/model/` | 数据库表映射、软删除、审计字段 |
| 控制器层 | `app/Http/Controllers/Api/` | 路由入口、权限注解、参数注入、响应返回 |
| 服务层 | `app/Services/` | 业务逻辑、属性配置、生命周期钩子、横切处理 |
| 模型层 | `app/Models/` | 数据库表映射、软删除、审计字段 |
验证职责分层:**FormRequest 只做格式验证,Service 只做业务规则**(唯一性、状态依赖)。
### 3. 模板方法模式(声明式 CRUD)
通过 `BaseLogic` 基类,将通用 CRUD 流程固化为模板方法。子类只需声明差异点(过滤字段、排序规则、唯一性校验等),即可获得完整的增删改查能力,极大减少重复代码。
通过 `BaseService` 基类,将通用 CRUD 流程固化为模板方法。子类只需声明差异点(过滤字段、排序规则、唯一性校验等),即可获得完整的增删改查能力,极大减少重复代码。
```php
// 子类只需声明差异点,即可获得完整 CRUD 能力
class ExampleLogic extends BaseLogic
class ExampleService extends BaseService
{
use HasCrudOperations, HasQueryScopes, HasSerializeMaps, HasUniqueCheck;
/**
* 关联的模型类
*
* @var string
* 绑定的模型类
*/
protected string $modelClass = Example::class;
/**
* LIKE 模糊匹配字段
*
* @var array
* @var array<int, string>
*/
protected array $pageLikeFields = ['name'];
/**
* 精确匹配字段
*
* @var array
* @var array<int, string>
*/
protected array $pageEqFields = ['status'];
/**
* 默认排序规则
*
* @var array
*/
protected array $pageOrderBy = ['field'=>'sort', 'type'=>'asc'];
/**
* 唯一性校验字段
*
* @var array
* @var array<int, string>
*/
protected array $uniqueFields = ['name'];
/**
* 枚举显示名映射
*
* @var array
* @var array<string, string>
*/
protected array $serializeMaps = ['status'=>'example_status'];
protected array $serializeMaps = ['status' => 'example_status'];
}
```
### 4. 中间件链式处理
请求处理采用中间件链,按注册顺序依次执行:
请求处理采用中间件链,在 `bootstrap/app.php` 中注册别名与分组:
```text
请求进入 → CORS → Auth → Tenant → Demo → Log → 控制器
请求进入 → Proxy → Cors → ForceJson → auth.jwt → tenant → log.operation → 控制器
└────────── 全局 api 组 ─────────┘└──────── 路由中间件组 ────────┘
│
响应返回 ← CORS ← Auth ← Tenant ← Demo ← Log ←───┘
响应返回 ←────────────(逆向穿过)──────────────────┘
(log.operation 在响应后 terminable 阶段落库)
```
| 中间件 | 职责 | 配置方式 |
|--------|------|----------|
| CorsMiddleware | 跨域处理 | 全局,config/cors.php |
| AuthMiddleware | JWT 认证 + RBAC 鉴权 | 路由级,config/middleware.php |
| TenantMiddleware | 租户上下文注入 | 路由级 |
| DemoMiddleware | 演示环境拦截 | 路由级,.env [APP] DEMO |
| LogMiddleware | 操作日志记录 | 路由级,config/middleware.php |
| 中间件 | 别名 | 作用域 | 职责 |
|--------|------|--------|------|
| `ProxyMiddleware` | proxy | 全局(api prepend) | 受信代理真实 IP 提取 |
| `Cors` | cors | 全局 | 跨域处理(`config/cors.php`) |
| `ForceJsonResponse` | force.json | 全局 | 强制 JSON Accept 头 |
| `Authenticate` | auth.jwt | 路由组 | JWT 认证 + `#[Permission]` 反射鉴权 |
| `ResolveTenant` | tenant | 路由组 | 租户上下文解析注入 |
| `LogOperation` | log.operation | 路由组 | 操作日志采集(terminable 落库) |
| `DemoMiddleware` | demo | 路由别名 | 演示环境写操作拦截(`#[DemoAllow]` 放行) |
### 5. 全局异常处理
`ExceptionHandle` 统一捕获所有异常并返回标准 JSON 响应:
`App\Exceptions\Handler` 统一捕获 `api/*` 请求的异常并返回标准 JSON 响应:
| 来源 | 异常类型 | 触发场景 | 响应 |
|------|---------|---------|------|
| ExceptionHandle | ValidateException | 参数验证失败 | `{code:1, msg:"参数验证失败", data:{错误明细}}` |
| ExceptionHandle | ModelNotFoundException | 数据不存在 | `{code:1, msg:"数据不存在"}` |
| ExceptionHandle | HttpException | HTTP 错误 | `{code:1, msg:"请求错误"}` |
| ExceptionHandle | Exception | 未捕获异常 | `{code:1, msg:"操作失败"}` |
| AuthMiddleware | — | JWT 缺失/过期/无效、token 类型错误 | `{code:401, msg:"未提供认证令牌"}` 等 |
| PermissionMiddleware | — | 无接口权限 | `{code:403, msg:"没有权限"}` |
| Handler | `BizException` | 业务规则失败(Service 抛出) | `{code:异常code(默认1), msg:"业务提示"}` |
| Handler | `ValidationException` | 代码内手动校验失败 | `{code:422, msg:"参数验证失败", data:{错误明细}}` |
| Handler | `AuthenticationException` | 未登录 / 令牌失效 | `{code:401, msg:"未授权,请先登录"}` |
| Handler | `NotFoundHttpException` | 路由不存在 | `{code:404, msg:"数据不存在"}` |
| Handler | 其它异常 | 未捕获异常 | `{code:500, msg:调试模式为原文,生产为"操作失败"}` |
| FormRequest | `HttpResponseException` | 表单格式校验失败 | `{code:1, msg:"参数验证失败", data:{字段=>错误数组}}` |
| auth.jwt 中间件 | — | Token 缺失/过期/无权限 | `{code:401/403, ...}`(到达控制器前拦截) |
### 6. 统一响应格式
@@ -172,7 +179,7 @@ class ExampleLogic extends BaseLogic
}
```
成功响应 `code=0`,失败响应 `code=1`(ExceptionHandle 统一处理)。`code=401`(AuthMiddleware 直接返回)和 `code=403`(PermissionMiddleware 直接返回)由中间件在请求到达控制器之前拦截,不经过 ExceptionHandle。前端无需针对不同接口做差异化解析。
成功响应 `code=0`,失败响应 `code=1`。HTTP 状态码默认 200,仅 403 同时设置对应 HTTP 状态码(便于网关处理),`code=401` 由前端跳转登录。前端无需针对不同接口做差异化解析。见 [5.16 统一响应与异常抛出](/zh/core/exception.html)。
### 7. 软删除策略
@@ -180,17 +187,17 @@ class ExampleLogic extends BaseLogic
所有业务数据必须使用软删除(`is_delete` 字段),禁止物理删除。物理删除仅用于日志清理等特殊场景。
:::
所有业务表通过 `is_delete` 字段实现软删除(0=正常,1=已删除)。`BaseModel` 的全局查询范围自动追加 `is_delete=0` 过滤,业务代码无需关心已删除数据。
所有业务表通过 `is_delete` 字段实现软删除(0=正常,1=已删除)。`SoftDeleteFlagScope` 全局查询范围自动追加 `is_delete=0` 过滤,业务代码无需关心已删除数据。
```php
// 自动过滤已删除数据
$users = User::select(); // WHERE is_delete = 0
$users = User::query()->get(); // WHERE is_delete = 0
// 查询已删除数据
$deleted = User::withoutGlobalScope(['soft_delete'])->select();
$deleted = User::withoutGlobalScope(SoftDeleteFlagScope::class)->get();
// 软删除
$user->softDelete(); // UPDATE SET is_delete = 1
$user->softDelete(); // UPDATE SET is_delete = 1
// 批量软删除
User::batchSoftDelete([1, 2, 3]);
@@ -198,27 +205,25 @@ User::batchSoftDelete([1, 2, 3]);
### 8. 审计字段自动写入
通过 BaseModel 的模型事件自动填充审计字段:
通过 `HasAuditColumns` Trait 的模型事件自动填充审计字段:
```php
// 创建前自动填充
public static function onBeforeInsert($model): void
{
$model->create_user = request()->userInfo->username ?? '';
$model->create_time = date('Y-m-d H:i:s');
}
// 创建时自动填充(已赋值则保留原值)
static::creating(function ($model) {
$model->create_user = $model->create_user ?? Auth::username();
$model->create_time = $model->create_time ?? now();
});
// 更新前自动填充
public static function onBeforeUpdate($model): void
{
$model->update_user = request()->userInfo->username ?? '';
$model->update_time = date('Y-m-d H:i:s');
}
// 更新时自动填充(强制覆盖)
static::updating(function ($model) {
$model->update_user = Auth::username();
$model->update_time = now();
});
```
### 9. 多数据库支持
通过 `DATABASE.TYPE` 环境变量切换数据库驱动(MySQL / PostgreSQL / SQL Server / Oracle / SQLite),ThinkPHP ORM 的方言层屏蔽了底层差异,业务代码无需修改。
通过 `DB_CONNECTION` 环境变量切换数据库驱动(MySQL / PostgreSQL / SQL Server / Oracle / SQLite),`document/` 下提供五套初始化脚本;Oracle 通过自定义 `oci` 驱动接入(`AppServiceProvider` 注册,配套 `OracleGrammar` / `OracleProcessor`)。业务代码无需修改。
### 10. 两级缓存架构
@@ -226,13 +231,13 @@ public static function onBeforeUpdate($model): void
请求级缓存(static 变量) ← 同一请求内不重复查库
│ 未命中
▼
持久缓存(file / Redis) ← 跨请求复用,1小时过期
持久缓存(Cache 门面/Redis) ← 跨请求复用,1小时过期
│ 未命中
▼
数据库查询 ← 查询后写入两级缓存
```
DictService 和 ParamService 均采用此架构,大幅减少数据库查询。
`DictCacheService` 采用此架构,配合 Observer(`DictItemObserver` / `MenuObserver` / `RoleMenuObserver`)在数据变更时自动失效缓存。详见 [4.8 缓存架构设计](/zh/architecture/cache.html)。
## 技术分层图
@@ -243,59 +248,59 @@ DictService 和 ParamService 均采用此架构,大幅减少数据库查询。
└──────────────────────────┬───────────────────────────────────┘
│ HTTP / JSON
┌──────────────────────────┴───────────────────────────────────┐
│ ThinkPHP 6.1 Application │
│ Laravel 13 Application │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────────┐ │
│ │ 中间件 │ │ 路由 │ │ 异常处理器 │ │
│ │ ───────── │ │ ───────── │ │ ───────────────── │ │
│ │ CORS │ │ route/ │ │ ValidateException │ │
│ │ Auth │ │ app.php │ │ ModelNotFound │ │
│ │ Tenant │ │ │ │ RuntimeException │ │
│ │ Demo │ │ │ │ HttpException │ │
│ │ Log │ │ │ │ Exception │ │
│ │ Proxy │ │ routes/ │ │ BizException │ │
│ │ Cors │ │ api.php │ │ ValidationException │ │
│ │ ForceJson │ │ web.php │ │ Authentication │ │
│ │ auth.jwt │ │ console.php│ │ NotFoundHttp │ │
│ │ tenant │ │ │ │ Exception(500) │ │
│ │ log.oper. │ │ │ │ │ │
│ │ demo │ │ │ │ │ │
│ └─────────────┘ └─────────────┘ └──────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ 控制器层(app/controller/) │ │
│ │ BaseController · #[Permission] · #[Log] │ │
│ │ 控制器层(app/Http/Controllers/Api/)28 个 │ │
│ │ Controller 基类 · #[Permission] · #[OperationLog] │ │
│ │ FormRequest 注入(24 个) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ 逻辑层(app/logic/) │ │
│ │ BaseLogic / BaseTenantLogic │ │
│ │ 属性配置 · 钩子 · 文件处理 · 枚举翻译 · 数据权限 │ │
│ │ 服务层(app/Services/)36 个 │ │
│ │ BaseService / BaseTenantService │ │
│ │ 6 Trait:CRUD · 查询 · 文件 · 数据权限 · 字典 · 唯一 │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ 模型层(app/model/) │ │
│ │ BaseModel · 软删除 · 审计字段 │ │
│ │ 模型层(app/Models/)25 个 │ │
│ │ SoftDeletesByFlag · HasAuditColumns │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────┐ ┌─────────────┐ ┌──────────────────────┐ │
│ │ 服务层 │ │ 验证器 │ │ 注解 │ │
│ │ ────────────── │ │ ───────── │ │ ───────────────── │ │
│ │ JwtService │ │ Validate │ │ #[Log] │ │
│ │ PasswordService │ │ │ │ #[Permission] │ │
│ │ DictService │ │ │ │ #[DemoAllow] │ │
│ │ ParamService │ │ │ │ │ │
│ │ ExcelService │ │ │ │ │ │
│ │ CaptchaService │ │ │ │ │ │
│ │ GeneratorService│ │ │ │ │ │
│ │ AttributeService│ │ │ │ │ │
│ │ DbMigrateService│ │ │ │ │ │
│ │ DbSchemaBuilder │ │ │ │ │ │
│ │ RequestInfoServ │ │ │ │ │ │
│ └──────────────────┘ └─────────────┘ └──────────────────────┘ │
│ ┌──────────────────┐ ┌─────────────┐ ┌──────────────────┐ │
│ │ 横切服务 │ │ FormRequest │ │ 注解(Attributes)│ │
│ │ ────────────── │ │ ────────── │ │ ────────────── │ │
│ │ JwtService │ │ BaseRequest │ │ Permission │ │
│ │ PasswordService │ │ 24 个 │ │ OperationLog │ │
│ │ DictCacheService │ │ │ │ DemoAllow │ │
│ │ CaptchaService │ │ │ │ │ │
│ │ ExcelService │ │ │ │ │ │
│ │ GeneratorService│ │ │ │ │ │
│ │ DbMigrateService│ │ │ │ │ │
│ │ RequestInfo... │ │ │ │ │ │
│ └──────────────────┘ └─────────────┘ └──────────────────┘ │
└──────────────────────────┬───────────────────────────────────┘
│
┌────────────┴────────────┐
▼ ▼
┌─────────────┐ ┌─────────────┐
│ MySQL │ │ Redis │
│ PostgreSQL │ │ 缓存 │
│ SQL Server │ └─────────────┘
│ Oracle │
│ SQLite │
│ PostgreSQL │ │ 会话/缓存/队列│
│ SQL Server │ └─────────────┘
│ Oracle │
│ SQLite │
└─────────────┘
```
@@ -304,26 +309,27 @@ DictService 和 ParamService 均采用此架构,大幅减少数据库查询。
每个业务模块遵循统一的文件结构:
```text
app/controller/UserController.php # HTTP 入口
app/logic/UserLogic.php # 业务逻辑
app/model/User.php # 数据模型
app/validate/UserValidate.php # 参数验证
app/Http/Controllers/Api/UserController.php # HTTP 入口
app/Http/Requests/UserRequest.php # 参数验证
app/Services/UserService.php # 业务逻辑
app/Models/User.php # 数据模型
```
| 文件 | 职责 | 基类 |
|------|------|------|
| Controller | 接收请求、调用 Logic、返回响应 | BaseController |
| Logic | 业务逻辑、属性配置、钩子处理 | BaseLogic / BaseTenantLogic |
| Model | 数据库表映射 | BaseModel |
| Validate | 参数校验规则 | ThinkPHP Validate |
| Controller | 接收请求、调用 Service、返回响应 | `App\Http\Controllers\Controller` |
| Request | 参数格式校验 | `BaseRequest` |
| Service | 业务逻辑、属性配置、钩子处理 | `BaseService` / `BaseTenantService` |
| Model | 数据库表映射 | Eloquent Model + Concerns Trait |
## 关键设计决策
| 决策 | 选择 | 原因 |
|------|------|------|
| 认证方案 | JWT 双令牌 | 无状态,适合分布式部署 |
| 删除策略 | 软删除(is_delete) | 数据可恢复,避免误删 |
| 字段命名 | 数据库 snake_case / 前端 camelCase | 各端遵循各自惯例 |
| 缓存策略 | 两级缓存(请求级 + 持久缓存) | 减少数据库查询 |
| 数据库 | 多数据库支持 | 适配不同客户需求 |
| 权限模型 | RBAC(用户→角色→菜单) | 灵活的权限配置 |
| 认证方案 | JWT 双令牌(Redis 有状态) | 无状态便于水平扩展,有状态支持踢下线/改密全端失效 |
| 删除策略 | 软删除(`is_delete`) | 数据可恢复,唯一性校验可排除已删数据 |
| 字段命名 | 数据库 snake_case / 响应 camelCase | `API_CAMEL_SNAKE_CONVERT` 自动转换,各端遵循各自惯例 |
| 缓存策略 | 两级缓存(请求级 + Redis) | 减少数据库查询,Observer 保证一致性 |
| 数据库 | 五种数据库 + 五套脚本 | 适配不同客户需求 |
| 权限模型 | RBAC(用户→角色→菜单,菜单即权限) | 灵活的权限配置,注解声明即生效 |
| 安全基线 | JWT_SECRET fail-close、生产 APP_DEBUG=false | 敏感信息不泄露、弱密钥不启动 |
@@ -1,10 +1,10 @@
# 4.2 请求处理链路
:::tip 链路概览
一个典型的 API 请求经过以下阶段:浏览器 → Nginx → CORS 中间件 → Auth 中间件(JWT 认证 + RBAC 鉴权)→ Tenant 中间件 → Demo 中间件 → Log 中间件 → 控制器 → 逻辑层 → 模型层 → 数据库 → 响应序列化 → 浏览器
一个典型的 API 请求经过以下阶段:浏览器 → Nginx → Proxy 中间件(真实 IP)→ CORS → 强制 JSON → auth.jwt(JWT 认证 + RBAC 鉴权)→ tenant(租户上下文)→ log.operation(操作日志采集)→ FormRequest 校验 → 控制器 → 服务层 → 模型层 → 数据库 → 响应序列化 → 浏览器 →(响应后)操作日志落库
:::
本节以 `POST /api/example/add` 添加案例为例,逐步拆解完整链路。
本节以 `POST /api/example/add` 添加案例为例,逐步拆解完整链路(代码摘自项目实际源码)。
## 第一阶段:网络层
@@ -22,18 +22,17 @@ export function exampleAdd(data: any) {
```
前端通过 Axios 发起 HTTP POST 请求,携带:
- `Authorization: Bearer <JWT Token>` 请求头(由请求拦截器自动添加)
- `Content-Type: application/json` 请求头
- JSON 请求体
### 2. Nginx 反向代理(生产环境)
### 2. Nginx 反向代理(生产环境 / Docker)
```nginx
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# docker/nginx.conf(本项目实际配置)
location / {
try_files $uri $uri/ /index.php?$query_string;
}
```
@@ -53,112 +52,197 @@ server: {
## 第二阶段:路由解析
### 3. ThinkPHP 入口
### 3. Laravel 入口
```text
public/index.php → 引导框架 → 路由解析 route/app.php
public/index.php → bootstrap/app.php(中间件别名/分组、健康检查)→ 路由解析 routes/api.php
```
### 4. 路由匹配
```php
// route/app.php
// routes/api.php
// 公开接口(无需认证)
Route::group('api', function () {
Route::post('login', 'LoginController/login');
Route::get('captcha', 'LoginController/captcha');
// 公开接口(无需认证)——放在中间件组外
Route::post('login', [Api\AuthController::class, 'login']);
// 认证接口
Route::middleware(['auth.jwt', 'tenant', 'demo', 'log.operation'])->group(function () {
// --- 案例演示 ---
Route::post('example/add', [Api\ExampleController::class, 'add']); // ← 匹配到此处
});
```
// 需认证接口
Route::group('api', function () {
Route::group('example', function () {
Route::post('add', 'ExampleController/add'); // ← 匹配到此处
});
})->middleware([
\app\middleware\AuthMiddleware::class, // JWT 认证 + 权限校验
\app\middleware\TenantMiddleware::class, // 租户上下文
\app\middleware\DemoMiddleware::class, // 演示环境拦截
\app\middleware\LogMiddleware::class, // 操作日志
同时,`bootstrap/app.php` 的全局 `api` 中间件组已 prepend 三个前置中间件:
```php
// bootstrap/app.php
$middleware->alias([
'proxy' => ProxyMiddleware::class,
'cors' => Cors::class,
'force.json' => ForceJsonResponse::class,
'auth.jwt' => Authenticate::class,
'tenant' => ResolveTenant::class,
'demo' => DemoMiddleware::class,
'log.operation' => LogOperation::class,
]);
// API 中间件组(按顺序:Proxy → Cors → ForceJson)
$middleware->api(prepend: [
ProxyMiddleware::class,
Cors::class,
ForceJsonResponse::class,
]);
```
路由匹配到 `POST api/example/add` → `ExampleController::add()`,同时绑定四个中间件。
完整链路:`ProxyMiddleware → Cors → ForceJsonResponse → auth.jwt → tenant → log.operation → 控制器`。
## 第三阶段:中间件链(按顺序执行)
### 5. CorsMiddleware(全局中间件)
### 5. ProxyMiddleware(全局,真实 IP)
```php
// app/middleware/CorsMiddleware.php
// app/Http/Middleware/ProxyMiddleware.php(节选)
public function handle(Request $request, \Closure $next): Response
public function handle(Request $request, Closure $next): Response
{
// 处理 OPTIONS 预检请求
if ($request->isOptions()) {
$response = Response::create('', 'html', 204);
$remoteAddr = $request->server('REMOTE_ADDR', '');
$proxyIps = $this->getTrustedProxies();
// 当前连接来源不在受信列表中,跳过(防止伪造代理头)
if (! $this->isTrustedProxy($remoteAddr, $proxyIps)) {
return $next($request);
}
// 按优先级提取真实 IP(X-Real-IP > X-Forwarded-For 第一个合法值)
$realIp = $this->extractRealIp($request);
if (! empty($realIp) && $realIp !== $remoteAddr) {
// 通过 server() 方法替换 REMOTE_ADDR,后续 ip() 即返回真实 IP
$server = $request->server->all();
$server['REMOTE_ADDR'] = $realIp;
$request->server->replace($server);
}
return $next($request);
}
```
- 部署在 CDN / 负载均衡后时保证 `request()->ip()` 正确(日志、限流依赖)
- 受信代理由 `.env` 的 `TRUSTED_PROXIES` 配置,未配置默认信任 `127.0.0.1` 与 `::1`
### 6. Cors(全局,跨域处理)
```php
// app/Http/Middleware/Cors.php(节选)
public function handle(Request $request, Closure $next): Response
{
// 预检请求直接返回,不进入业务逻辑(Authenticate 等中间件不会执行)
if ($request->isMethod('OPTIONS')) {
$response = response('', 204);
} else {
$response = $next($request);
}
// 设置跨域响应头
$response->header([
'Access-Control-Allow-Origin' => $origin,
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, ...',
'Access-Control-Allow-Credentials' => 'true',
]);
$config = config('cors');
$origin = $request->header('Origin', '');
$allowOrigins = $config['allow_origin'] ?? ['*'];
$allowCredentials = $config['allow_credentials'] ?? true;
$originAllowed = false;
if (in_array('*', $allowOrigins)) {
if ($allowCredentials && $origin) {
// 凭证模式下不能用 *,回显具体 Origin
$response->headers->set('Access-Control-Allow-Origin', $origin);
$response->headers->set('Vary', 'Origin'); // 防止 CDN 缓存错误的 CORS 响应
} else {
$response->headers->set('Access-Control-Allow-Origin', '*');
}
$originAllowed = true;
} elseif ($origin && in_array($origin, $allowOrigins)) {
// 白名单匹配:仅回显请求的 Origin
$response->headers->set('Access-Control-Allow-Origin', $origin);
$response->headers->set('Vary', 'Origin');
$originAllowed = true;
}
// 仅在 Origin 允许时设置其余 CORS 头
if ($originAllowed) {
$response->headers->set('Access-Control-Allow-Methods', $config['allow_methods'] ?? 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
$response->headers->set('Access-Control-Allow-Headers', $config['allow_headers'] ?? 'Content-Type, Authorization');
$response->headers->set('Access-Control-Expose-Headers', $config['expose_headers'] ?? 'Authorization');
$response->headers->set('Access-Control-Max-Age', $config['max_age'] ?? 3600);
if ($allowCredentials) {
$response->headers->set('Access-Control-Allow-Credentials', 'true');
}
}
return $response;
}
```
- 每个请求都经过(全局中间件)
- OPTIONS 预检请求直接返回 204
- 普通请求附加 CORS 响应头
### 6. AuthMiddleware(JWT 认证 + 权限校验)
### 7. ForceJsonResponse(全局,强制 JSON)
```php
// app/middleware/AuthMiddleware.php
// app/Http/Middleware/ForceJsonResponse.php
public function handle(Request $request, \Closure $next): Response
public function handle(Request $request, Closure $next): Response
{
// 1. 检查排除路由(login/captcha 等直接放行)
$path = $request->pathinfo();
foreach ($exceptList as $except) {
if (strpos($path, $except) !== false) {
return $next($request);
}
// 强制声明期望 JSON,确保异常与验证失败均以 JSON 返回
$request->headers->set('Accept', 'application/json');
return $next($request);
}
```
使后续 `Handler::render()` 的 `$request->wantsJson()` 恒为 true,框架错误页不会以 HTML 形式返回。
### 8. Authenticate(auth.jwt,JWT 认证 + 权限校验)
```php
// app/Http/Middleware/Authenticate.php(节选)
public function handle(Request $request, Closure $next): Response
{
// 获取 token:带 Bearer 前缀则剥离,裸 token 直接使用
$header = $request->header('Authorization', '');
if ($header === '') {
return response()->json([
'code' => 401, 'ok' => false,
'msg' => '未提供认证令牌', 'data' => null,
]);
}
// 2. 从请求头获取 Token
$token = Jwt::getTokenFromHeader();
if (empty($token)) {
return json(['code' => 401, 'msg' => '未提供认证令牌']);
}
$prefix = config('jwt.header_prefix', 'Bearer ');
$token = str_starts_with($header, $prefix) ? substr($header, strlen($prefix)) : $header;
// 3. 解析 Token,校验签名和有效期
// 解析 token
$jwtService = app(JwtService::class);
try {
$decoded = Jwt::decode($token);
} catch (\RuntimeException $e) {
return json(['code' => 401, 'msg' => $e->getMessage()]);
$decoded = $jwtService->parseToken($token);
} catch (\Exception $e) {
// 令牌解析失败:body code=401,消息取自异常
return response()->json([
'code' => 401, 'ok' => false,
'msg' => $e->getMessage(), 'data' => null,
]);
}
// 4. 校验 token 类型必须为 access
if (($decoded->type ?? '') !== 'access') {
return json(['code' => 401, 'msg' => '无效的认证令牌']);
}
// 注入用户信息,供控制器通过 $request->attributes->get('userInfo') 读取
$request->attributes->set('userInfo', $decoded);
// 5. 注入用户信息到 Request
$request->userInfo = $decoded;
// 6. 读取 #[Permission] 注解,校验权限
$permission = AttributeService::getPermission($controllerClass, $action);
if ($permission !== null) {
$hasPermission = $this->checkPermission($decoded->uid, $permission->code);
if (!$hasPermission) {
return json(['code' => 403, 'msg' => '无访问权限']);
// 权限校验:仅当方法标注了 #[Permission] 时才校验
$permissionCode = $this->getPermissionCode($request);
if ($permissionCode !== null) {
$uid = $decoded->uid ?? 0;
if (! $this->checkPermission($uid, $permissionCode)) {
return response()->json([
'code' => 403, 'ok' => false,
'msg' => '无访问权限:'.$permissionCode, 'data' => null,
], 403);
}
}
@@ -171,57 +255,76 @@ public function handle(Request $request, \Closure $next): Response
```text
请求进入
│
├─ 路径在排除列表? → 直接放行
├─ 提取 Authorization 头(带 Bearer 前缀则剥离,裸 token 亦可)
│ └─ 头为空 → 401「未提供认证令牌」
│
├─ 提取 Authorization: Bearer <token>
│ └─ 为空 → 返回 401
├─ JwtService::parseToken($token) 解析验证
│ └─ 失败(过期/签名错误)→ 401(消息取自异常)
│
├─ Jwt::decode($token) 解析验证
│ └─ 失败(过期/签名错误)→ 返回 401
├─ 注入 $request->attributes['userInfo'] = {uid, username, ...}
│
├─ 校验 type === 'access'
│ └─ 不是 → 返回 401
│
├─ 注入 $request->userInfo = {uid, username, type, exp}
│
├─ 读取 #[Permission] 注解
│ ├─ 无注解 → 跳过权限校验
│ └─ 有注解 → checkPermission()
├─ 反射读取控制器方法的 #[Permission] 注解(ReflectionMethod,微秒级)
│ ├─ 无注解 → 跳过权限校验,仅完成认证
│ └─ 有注解 → checkPermission(uid, code)
│ ├─ uid === 1 → 直接放行(超级管理员)
│ └─ 查询 UserRole → RoleMenu → Menu.permission
│ ├─ 包含 → 放行
│ └─ 不包含 → 返回 403
│ ├─ Redis 读 perms:{uid}
│ │ ├─ 命中 *:*:* 或含目标编码 → 通过
│ │ └─ Redis 不可用 → 降级
│ ├─ Laravel Cache 读 perms_fallback:{uid}(300 秒)
│ └─ 未命中 → 查库 user_role → role_menu → menu.permission
│ ├─ 包含 → 放行(回填二级缓存)
│ └─ 不包含 → 403「无访问权限:sys:example:add」
│
└─ 放行 → 进入下一个中间件
```
### 7. TenantMiddleware(租户上下文)
:::tip 三级权限缓存
Redis(`perms:{uid}`,一级)→ Laravel Cache(`perms_fallback:{uid}` 5 分钟,二级)→ 数据库(回填)。菜单/角色菜单变更时 Observer 清除相关缓存,保证权限调整即时生效。
:::
### 9. ResolveTenant(tenant,租户上下文)
```php
// app/middleware/TenantMiddleware.php
// app/Http/Middleware/ResolveTenant.php(节选)
public function handle(Request $request, \Closure $next): Response
public function handle(Request $request, Closure $next): Response
{
$userInfo = $request->userInfo ?? null;
$userId = $userInfo->uid ?? 0;
$userInfo = $request->attributes->get('userInfo');
if (! $userInfo) {
// 未认证请求:不注入租户上下文,直接放行
return $next($request);
}
$uid = $userInfo->uid ?? 0;
// 超级管理员(uid=1):跳过租户校验,直接注入 tenantId=0
if ($uid === 1) {
$request->attributes->set('tenantId', 0);
$request->attributes->set('isSuperAdmin', true);
return $next($request);
}
// 非超管:查库取 tenant_id(JWT 不含 tenant_id,需查库)
$user = User::find($uid);
if (! $user) {
return response()->json([
'code' => 401, 'ok' => false,
'msg' => '用户不存在,请重新登录', 'data' => null,
]);
}
$user = User::find($userId);
$tenantId = $user->tenant_id ?? 0;
$request->attributes->set('tenantId', $tenantId);
$request->attributes->set('isSuperAdmin', false);
// 注入租户上下文
$request->tenantId = $tenantId;
$request->isSuperAdmin = ($tenantId == 0);
// 非超级管理员校验租户状态
// 平台用户(tenantId=0):跳过租户校验
if ($tenantId > 0) {
$tenant = Tenant::find($tenantId);
if (!$tenant || $tenant->status != 1) {
return json(['code' => 403, 'msg' => '租户已被禁用']);
}
if ($tenant->expire_time && strtotime($tenant->expire_time) < time()) {
return json(['code' => 403, 'msg' => '租户已过期']);
}
$request->tenantInfo = $tenant;
// 租户不存在 / 被禁用(status != 1)/ 已过期(expire_time->isPast())
// → 均返回 403 对应提示
$request->attributes->set('tenantInfo', $tenant);
}
return $next($request);
@@ -229,208 +332,177 @@ public function handle(Request $request, \Closure $next): Response
```
注入内容:
- `$request->tenantId` — 租户 ID(0=超级管理员)
- `$request->isSuperAdmin` — 是否超级管理员
- `$request->tenantInfo` — 租户信息对象
### 8. DemoMiddleware(演示环境拦截)
- `tenantId` — 租户 ID(0=平台用户)
- `isSuperAdmin` — 是否超级管理员(uid=1)
- `tenantInfo` — 租户模型实例(`tenantId > 0` 时)
### 10. LogOperation(log.operation,操作日志采集)
`handle` 阶段**只收集不写入**,`terminate` 阶段在响应完成后落库:
```php
// app/middleware/DemoMiddleware.php
// app/Http/Middleware/LogOperation.php(节选)
public function handle(Request $request, \Closure $next): Response
public function handle(Request $request, Closure $next): Response
{
// 非演示环境直接放行
if (!env('app_demo', false)) {
return $next($request);
$this->logContext = [
'source' => 0, // 来源端:0=后台,1=前台
'method' => strtoupper($request->method()), // HTTP 方法
'request_method' => $request->route()?->getActionMethod() ?? '', // 控制器方法名
'url' => $request->fullUrl(), // 完整请求地址
'param' => mb_substr(json_encode($request->except(['password', 'token']), JSON_UNESCAPED_UNICODE), 0, 2000), // 排除敏感字段,截断 2000 字
'ip' => $request->ip(),
];
$this->logAttr = $this->getLogAttribute($request); // 反射读取 #[OperationLog]
$this->shouldLog = $this->determineIfShouldLog($request->method(), $this->logAttr);
// 预创建请求信息服务(handle 阶段 request 信息完整,terminate 阶段可能已丢失)
if ($this->shouldLog) {
$this->requestInfo = RequestInfoService::create($request);
}
// GET 请求放行(读操作)
if (strtoupper($request->method()) === 'GET') {
return $next($request);
return $next($request);
}
public function terminate(Request $request, Response $response): void
{
if (! $this->shouldLog) {
return;
}
// 检查 #[DemoAllow] 注解
if (AttributeService::hasDemoAllow($controllerClass, $action)) {
return $next($request);
}
try {
// 补充响应内容、IP 归属地、操作系统、浏览器、耗时、状态
$this->logContext['result'] = mb_substr($response->getContent() ?? '', 0, 2000);
$this->logContext['location'] = $this->requestInfo->getIpLocation();
$this->logContext['os'] = $this->requestInfo->getOs();
$this->logContext['browser'] = $this->requestInfo->getBrowser();
$this->logContext['consume_time'] = defined('LARAVEL_START')
? round((microtime(true) - LARAVEL_START) * 1000) : 0;
$this->logContext['status'] = $this->detectStatus($response->getContent()); // code=0/200 → 0,其余 → 1
$this->logContext['create_user'] = $request->attributes->get('userInfo')->username ?? '';
// 拦截写操作
return json(['code' => 403, 'msg' => '演示环境,禁止操作']);
OperationLog::create($this->logContext);
} catch (\Exception $e) {
// 日志写入失败不影响业务
}
}
```
### 9. LogMiddleware(操作日志)
记录规则(`determineIfShouldLog`):
```php
// app/middleware/LogMiddleware.php
- `POST/PUT/DELETE/PATCH` 一律记录
- `GET` 仅在注解类型为 `EXPORT / DOWNLOAD / IMPORT` 时记录
- 其它方法(OPTIONS/HEAD)不记录
public function handle(Request $request, \Closure $next): Response
{
$startTime = microtime(true);
// 执行后续中间件和控制器
$response = $next($request);
// 写操作始终记录
if (in_array($method, ['POST', 'PUT', 'DELETE', 'PATCH'])) {
// 读取 #[Log] 注解
$logAttr = AttributeService::getLog($controllerClass, $action);
// 组装日志数据
$logData = [
'title' => $logAttr?->title ?: $path,
'type' => $logAttr?->type ?: $this->guessType($method),
'method' => $method,
'url' => $request->url(),
'param' => json_encode($request->param()),
'ip' => $requestInfo->getIp(),
'location' => $requestInfo->getIpLocation(),
'os' => $requestInfo->getOs(),
'browser' => $requestInfo->getBrowser(),
'consume_time'=> round((microtime(true) - $startTime) * 1000),
'status' => $this->detectStatus($response->getContent()),
];
// 写入数据库(失败不影响主业务)
OperationLog::create($logData);
}
return $response;
}
```
:::tip DemoMiddleware 说明
演示模式中间件(`demo` 别名)已挂载于认证路由组(`auth.jwt` → `tenant` → `demo` → `log.operation`):非演示模式(`DEMO_MODE=false`)直接放行;演示模式下 GET 放行、写操作需 `#[DemoAllow]` 注解,否则 403。拦截发生在 `LogOperation` 之前,被拦截请求不产生操作日志。
:::
## 第四阶段:控制器层
### 10. ExampleController::add()
### 11. FormRequest 参数校验(进入方法前)
```php
// app/controller/ExampleController.php
// 控制器方法签名声明 ExampleRequest,框架在校验通过后才调用方法
public function add(ExampleRequest $request): JsonResponse
```
#[Log('案例演示-新增记录', Log::TYPE_ADD, '新增案例演示:{name}')]
#[Permission('sys:example:add', '添加案例演示')]
public function add(): Json
- `rules()` 未通过 → `BaseRequest::failedValidation()` 直接返回
`{"code":1,"ok":false,"msg":"参数验证失败","data":{"name":["案例名称不能为空"]}}`
- 校验通过 → 控制器方法执行,`$request->validated()` 取已校验数据(键名转换在 Service 写入入口完成)
### 12. ExampleController::add()
```php
// app/Http/Controllers/Api/ExampleController.php
#[OperationLog('示例管理-新增', OperationLog::TYPE_ADD, '新增示例:{name}')]
#[Permission('sys:example:add', '添加示例')]
public function add(ExampleRequest $request): JsonResponse
{
// getJsonBody() 获取前端 POST 提交的 JSON 数据
// _add() 内部调用 $this->logic->add($data)
return parent::_add($this->logic, $this->getJsonBody());
return $this->_add($this->exampleService, $request->validated());
}
```
控制器层职责:
- 通过 `#[Permission]` 声明所需权限
- 通过 `#[Log]` 声明日志信息
- 获取参数,调用 Logic 层,返回响应
控制器层职责:注解声明权限与日志 → 参数由 FormRequest 保证 → 调用快捷方法 → 返回统一响应。
## 第五阶段:逻辑层
## 第五阶段:服务层
### 11. ExampleLogic::add()
### 13. ExampleService::add()(HasCrudOperations 主流程)
```php
// app/logic/ExampleLogic.php → 继承 BaseLogic
// BaseLogic::add() 的完整流程:
public function add(array $data): int
{
// 1. 钩子:新增前(可修改数据,抛异常可拦截)
$data = $this->beforeAdd($data);
// 2. 保留原始数据供 afterAdd 使用
$originalData = $data;
// 3. 入库前转换:camelCase → snake_case
$data = array_camel_to_snake($data);
// 4. 租户ID自动填充
$data = $this->processTenantId($data);
// 5. 文件字段:迁移临时文件,去掉域名
$data = $this->processFileFieldsOnSave($data);
// 6. 富文本字段:迁移临时文件
$data = $this->processContentFieldsOnSave($data);
// 7. 过滤非数据库字段
$saveData = $this->filterTableFields($data);
// 8. 唯一性校验
$this->checkUnique($saveData);
// 9. 写入数据库
$model = $this->getModel()->create($saveData);
$id = $model->id;
// 10. 钩子:新增后
$this->afterAdd($id, $originalData);
return $id;
}
```text
$this->_add($service, $data)
│
▼
Service::add($data) ← HasCrudOperations
├─ beforeAdd($data) 钩子 ← 可改写数据、抛 BizException 拦截
├─ checkUnique($data) ← uniqueFields 唯一性校验,重复抛 BizException
├─ DB::transaction { ← 事务包裹(任一异常自动回滚)
│ Model::create($data) ← Eloquent 写入
│ }
├─ afterAdd($id, $data) 钩子 ← 关联处理(抛异常同样回滚)
└─ return $id
```
:::tip 异常即控制流
`BizException` 在钩子/唯一性校验中抛出 → 中断事务闭包回滚 → 由 `_add()` 捕获转为 `fail(消息, code)`,或由全局 `Handler` 兜底——任何路径前端拿到的结构一致。
:::
## 第六阶段:模型层
### 12. BaseModel 自动处理
### 14. HasAuditColumns 自动填充 + SQL 执行
```php
// app/model/Example.php
class Example extends BaseModel
{
protected $name = 'example';
}
// BaseModel 提供的能力:
// 1. 全局查询范围 soft_delete → WHERE is_delete = 0
// 2. onBeforeInsert → 自动填充 create_user, create_time
// 3. onBeforeUpdate → 自动填充 update_user, update_time
// app/Models/Concerns/HasAuditColumns.php(节选)
static::creating(function ($model) {
$model->create_user = $model->create_user ?? Auth::username();
$model->create_time = $model->create_time ?? now();
});
```
### 13. 数据库执行
ThinkPHP ORM 将操作转换为 SQL:
Eloquent 将操作转换为 SQL:
```sql
INSERT INTO think_example (name, status, sort, avatar, create_user, create_time)
VALUES ('测试案例', 1, 0, '', 'admin', '2026-09-20 12:00:00');
INSERT INTO laravel_example (name, status, sort, create_user, create_time)
VALUES ('测试案例', 1, 0, 'admin', '2026-10-02 12:00:00');
```
返回自增 ID(MySQL `lastInsertId`;Oracle 走序列 CURRVAL,见 `OracleProcessor`)。
## 第七阶段:响应返回
### 14. 响应序列化
### 15. 响应序列化
```php
// BaseController::_add()
protected function _add(BaseLogic $logic, array $data): Json
{
$id = $logic->add($data);
return $this->success(['id' => $id], '添加成功');
}
// Controller::_add() 内部
$id = $service->add($data);
return $this->success(['id' => $id], '添加成功');
// Result::success()
public static function success($data, string $msg = '操作成功'): Json
{
return json([
'code' => 0,
'ok' => true,
'msg' => $msg,
'data' => self::convertData($data), // snake_case → camelCase
]);
}
// Support\Result::success()(节选)
return response()->json([
'code' => self::SUCCESS, // 0
'ok' => true,
'msg' => $msg,
'data' => convert_to_camel($data), // snake_case → camelCase
]);
```
### 15. 浏览器接收响应
### 16. 浏览器接收响应 + 日志落库
```json
{
"code": 0,
"ok": true,
"msg": "添加成功",
"data": {
"id": 42
}
"data": { "id": 42 }
}
```
前端 Axios 拦截器统一处理:`code === 0` 时展示成功提示,否则展示错误信息。
响应发送后,Laravel 调用 `LogOperation::terminate()` 将本次操作写入 `laravel_operation_log`(耗时、状态、归属地等)。
前端 Axios 拦截器统一处理:`code === 0` 时展示成功提示,否则展示错误信息;401 触发登出跳转。
## 完整链路流程图
@@ -440,93 +512,75 @@ public static function success($data, string $msg = '操作成功'): Json
│ Authorization: Bearer <token>
▼
[Nginx / Vite Proxy] ── 反向代理转发
│
▼
[ThinkPHP 入口] ── public/index.php
│
[public/index.php] ── bootstrap/app.php 引导
▼
[路由解析] ── route/app.php 匹配 api/example/add
│
[路由解析] ── routes/api.php 匹配 api/example/add(挂 auth.jwt + tenant + demo + log.operation)
▼
[CorsMiddleware] ── 跨域校验通过(全局)
│
[ProxyMiddleware] ── 受信代理校验,替换真实 REMOTE_ADDR(全局)
▼
[AuthMiddleware]
├─ 提取 Token → Jwt::decode()
├─ 校验 type === 'access'
├─ 注入 $request->userInfo
├─ 读取 #[Permission] 注解
├─ checkPermission() 权限校验
│ ├─ uid=1 → 跳过(超级管理员)
│ └─ UserRole → RoleMenu → Menu.permission
└─ 放行
│
[Cors] ── OPTIONS 直接 204;响应追加 CORS 头(全局)
▼
[TenantMiddleware]
├─ 查询用户 tenant_id
├─ 注入 $request->tenantId
└─ 校验租户状态(禁用/过期 → 403)
│
[ForceJsonResponse] ── Accept 强制 application/json(全局)
▼
[Authenticate]
├─ 提取 Authorization token(头为空 → 401)
├─ JwtService::parseToken()(过期/签名错 → 401)
├─ 注入 attributes['userInfo']
├─ 反射读取 #[Permission] 注解
└─ checkPermission():uid=1 跳过 → Redis perms:{uid} → Cache 降级 → 查库
└─ 不通过 → 403「无访问权限:sys:example:add」
▼
[ResolveTenant]
├─ 无 userInfo → 放行
├─ uid=1 → tenantId=0, isSuperAdmin=true
└─ 非超管 → 查库 tenant_id → 状态/过期校验(禁用/过期/不存在 → 403)
▼
[DemoMiddleware]
├─ 非演示环境 → 放行
├─ 非演示模式(DEMO_MODE=false)→ 放行
├─ GET 请求 → 放行
└─ 有 #[DemoAllow] → 放行
│
└─ 写操作:有 #[DemoAllow] 放行,否则 403「演示模式下禁止操作」
▼
[LogMiddleware] ── 记录开始时间
│
[LogOperation::handle] ── 收集上下文、读 #[OperationLog]、判断是否记录
▼
[ExampleRequest] ── rules() 校验(失败 → code=1 + 字段明细)
▼
[ExampleController::add()]
├─ #[Log] 注解元数据
├─ #[Permission] 注解元数据
└─ parent::_add($this->logic, $data)
│
└─ $this->_add($service, $request->validated())
▼
[ExampleLogic::add()]
[ExampleService::add()]
├─ beforeAdd() 钩子
├─ array_camel_to_snake() 键名转换
├─ processTenantId() 租户填充
├─ processFileFieldsOnSave() 文件处理
├─ filterTableFields() 字段过滤
├─ checkUnique() 唯一性校验
├─ Model::create() 写入数据库
├─ checkUnique()(重复 → BizException → 回滚 → code=1)
├─ DB::transaction { Model::create() }
│ └─ HasAuditColumns:create_user / create_time 自动填充
└─ afterAdd() 钩子
│
▼
[BaseModel]
├─ onBeforeInsert → create_user, create_time
└─ 执行 SQL INSERT
│
▼
[数据库] ── 返回自增 ID
│
▼ 逐层返回响应
[Result::success()] ── {code:0, ok:true, msg:"添加成功", data:{id:42}}
│
▼
[LogMiddleware] ── 记录操作日志(写操作)
│
[数据库] ── INSERT INTO laravel_example ... → 自增 ID
▼ 逐层返回
[Result::success] ── {code:0, ok:true, msg:"添加成功", data:{id:42}}(驼峰转换)
▼
[浏览器] ← JSON 响应
▼(响应完成后)
[LogOperation::terminate] ── 写入操作日志(失败静默,不影响业务)
```
## 异常处理链路
当链路中发生异常时,ExceptionHandle 统一捕获并返回标准响应:
当链路中发生异常时,`Handler` 统一捕获并返回标准响应:
| 异常类型 | 触发场景 | 响应 |
|---------|---------|------|
| `ValidateException` | 参数验证失败 | `{code:1, msg:"参数验证失败", data:{错误明细}}` |
| `ModelNotFoundException` | 数据不存在 | `{code:1, msg:"数据不存在"}` |
| `RuntimeException (401)` | JWT 过期/无效 | `{code:1, msg:"token已过期"}` |
| `HttpException` | HTTP 错误 | `{code:1, msg:"请求错误"}` |
| `Exception` | 未捕获异常 | `{code:1, msg:"操作失败"}`(生产环境隐藏详情) |
| `HttpResponseException`(FormRequest) | 表单格式校验失败 | `{code:1, msg:"参数验证失败", data:{字段=>错误数组}}` |
| `BizException` | 业务规则失败 | `{code:1(或自定义), msg:"业务提示"}` |
| `ValidationException` | 代码内手动校验失败 | `{code:422, msg:"参数验证失败", data:{错误明细}}` |
| `AuthenticationException` | 未登录 / 令牌失效 | `{code:401, msg:"未授权,请先登录"}` |
| `NotFoundHttpException` | 路由不存在 | `{code:404, msg:"数据不存在"}` |
| 其它异常 | 未捕获异常 | `{code:500, msg:调试模式为原文, 生产为"操作失败"}` |
:::warning 异常处理原则
所有异常均返回 HTTP 200 状态码,通过响应体 `code` 字段区分业务成功(0)与失败(1/401/403/404/422)。前端统一按 `code` 判断,无需处理 HTTP 状态码差异。
除 403(中间件直接返回时同时设置 HTTP 状态码)外,所有响应 HTTP 状态码为 200,通过响应体 `code` 字段区分业务成功(0)与失败。前端统一按 `code` 判断,`code=401` 时跳转登录,无需处理 HTTP 状态码差异。
:::
## 总结
一个 API 请求从浏览器到数据库经过 7 个阶段、15+ 个处理节点。中间件链负责横切关注点(认证、权限、租户、日志),控制器层负责参数获取和响应返回,逻辑层负责业务处理和数据加工,模型层负责数据库操作。这种分层设计使得每一层职责单一、可独立测试。
一个 API 请求从浏览器到数据库经过 7 个阶段、16+ 个处理节点。中间件链负责横切关注点(真实 IP、跨域、JSON 协商、认证、权限、租户、日志),FormRequest 负责参数格式,控制器层负责编排与响应封装,服务层负责业务处理与事务,模型层负责数据持久化。这种分层设计使得每一层职责单一、可独立测试。
@@ -4,42 +4,42 @@
本章详细阐述系统的安全架构设计,围绕认证、授权、防护、审计四大安全领域展开,旨在帮助开发者全面理解系统的安全保障体系。
:::
**认证(Authentication)**:系统采用基于 JWT 的无状态认证机制,用户通过用户名密码登录后获取 Token,后续请求通过 `Authorization: Bearer <token>` 头传递,由 `AuthMiddleware` 统一拦截并验证 Token 的有效性(签名校验、有效期检查)。
**认证(Authentication)**:系统采用基于 JWT 的双令牌认证机制,用户通过用户名密码登录后获取 Token,后续请求通过 `Authorization: Bearer <token>` 头传递,由 `Authenticate` 中间件(别名 `auth.jwt`)统一拦截并验证 Token 的有效性(签名校验、有效期检查),签名密钥启动时 fail-close 校验。
**授权(Authorization)**:基于 RBAC 模型设计,权限控制粒度细化至接口级。用户通过角色继承权限节点(如 `sys:user:add`),由 `#[Permission]` 注解在控制器层声明,`AuthMiddleware` 统一校验。管理员(`uid=1`)自动放行。
**授权(Authorization)**:基于 RBAC 模型设计,权限控制粒度细化至接口级。用户通过角色继承权限节点(如 `sys:user:add`),由 `#[Permission]` 注解在控制器层声明,`Authenticate` 中间件反射校验(三级缓存:Redis → Laravel Cache → 数据库)。超级管理员(`uid=1`)自动放行。
**防护(Protection)**:系统从多维度构建安全防护体系。参数校验通过 Validate 自动拦截非法输入;敏感密码采用 bcrypt 双重加盐哈希存储;演示模式下 `DemoMiddleware` 统一拦截写操作;跨域配置精确控制可信域名。
**防护(Protection)**:系统从多维度构建安全防护体系。参数校验通过 FormRequest 在 HTTP 入口自动拦截非法输入;敏感密码采用 bcrypt 双重加盐哈希存储;演示模式下 `DemoMiddleware` 统一拦截写操作;跨域配置精确控制可信域名;Nginx 仅允许入口 PHP 执行。
**审计(Audit)**:系统提供完备的操作审计能力。`LogMiddleware` 自动记录所有写操作的请求路径、参数、操作人、IP 地址及执行结果;登录日志独立记录每次登录尝试,便于安全事件追溯。
**审计(Audit)**:系统提供完备的操作审计能力。`LogOperation` 中间件自动记录所有写操作的请求路径、参数(排除敏感字段)、操作人、IP 归属地及执行结果;登录日志独立记录每次登录尝试,便于安全事件追溯。
:::tip 安全设计原则
- **纵深防御**:认证、授权、参数校验多层防护,单一防线失效时仍有其他机制兜底
- **最小权限**:用户仅能访问已授权接口,角色权限按需分配
- **数据不落地**:敏感信息(Token、密码)不在日志中明文记录,密码哈希不可逆。**注意**:当前 LogMiddleware 会记录完整请求参数,敏感字段(如密码)建议在业务层脱敏后再传入
- **安全默认**:框架默认配置为安全模式(DEBUG 关闭、错误信息隐藏)
- **数据不落地**:敏感信息(Token、密码)不在日志中明文记录(操作日志 `param` 排除 `password`/`token`),密码哈希不可逆
- **安全默认**:`JWT_SECRET` 弱密钥拒绝启动(fail-close),生产 `APP_DEBUG=false` 隐藏错误详情
:::
## 安全架构总览
```text
┌─────────────────────────────────────────────────────────────────┐
│ 安全防御层次 │
│ 安全防御层次 │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ 网络层防护 │ │
│ │ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ CORS │ │ 上传体积限制 │ │ │
│ │ │ 跨域控制 │ │ DoS 防御 │ │ │
│ │ └──────────┘ └──────────────┘ │ │
│ │ ┌──────────┐ ┌──────────────┐ ┌────────────────────────┐ │ │
│ │ │ CORS │ │ 上传体积限制 │ │ Nginx 仅入口 index.php │ │ │
│ │ │ 跨域控制 │ │ DoS 防御 │ │ 可见性攻击防护 │ │ │
│ │ └──────────┘ └──────────────┘ └────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ 认证层 │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ JWT 认证 │ │ 验证码 │ │ 双令牌 │ │ │
│ │ │ Bearer │ │ 图形验证 │ │ access+ │ │ │
│ │ │ │ │ │ │ refresh │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ JWT 认证 │ │ 验证码 │ │ 双令牌 │ │ 密钥 fail- │ │ │
│ │ │ Bearer │ │ 图形验证 │ │ access+ │ │ close 启动 │ │ │
│ │ │ │ │ + IP限流 │ │ refresh │ │ 校验 │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
@@ -54,7 +54,7 @@
│ │ 数据层 │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ 密码加密 │ │ 参数校验 │ │ SQL 注入 │ │ 操作日志 │ │ │
│ │ │ bcrypt │ │ Validate │ │ ORM 防护 │ │ 审计追踪 │ │ │
│ │ │ 双重加盐 │ │FormRequest│ │ ORM 防护 │ │ 审计追踪 │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
@@ -65,124 +65,145 @@
### 登录流程
```text
1. 前端提交 { username, password, code, key }
1. 前端提交 { username, password, key, code }
(验证码参数 key/code 与 /api/captcha 响应字段一致)
│
▼
2. CaptchaService::check(code, key) ← 验证码校验
2. CaptchaService::verify(key, code) ← 验证码校验(配置 CAPTCHA_LENGTH>0 时启用)
│ 失败 → 返回"验证码错误"
▼
3. User::where('username', $username) ← 查询用户
│ 不存在 → 统一提示"用户名或密码错误"
3. JwtService::login(username, password) ← 查询用户 + 密码校验
│ 用户不存在/密码错误 → 统一提示(不暴露账号是否存在)
▼
4. password_verify($password . $salt, $hashed) ← bcrypt 校验
│ 不匹配 → 统一提示"用户名或密码错误"
4. password_verify(password + salt, hash) ← bcrypt 双重加盐校验
│ 不匹配 → 统一提示
▼
5. 检查 user.status == 1 ← 账号状态
5. 检查 user.status == 1 ← 账号状态
│ 非启用 → 返回"账号已被禁用"
▼
6. JwtService::createTokenPair($uid) ← 签发双令牌
6. 签发双令牌 + 写登录日志(成功/失败均记录)
│
▼
7. 返回 { access_token, refresh_token, expires_in }
7. 返回 { access_token, refresh_token, token_type, expires_in, user }
```
### JWT 双令牌认证
```html
[前端] [后端]
│ │
│── POST /api/login ────→│
│ {username, password} │
│ │── 验证码校验
│ │── 密码校验(bcrypt)
│ │── 签发 access_token + refresh_token
│←─ {access_token, ───────│
│ refresh_token} │
│ │
│── GET /api/xxx ────────→│
│ Authorization: │
│ Bearer <access_token> │
│ │── 解析 JWT
│ │── 校验签名 + 有效期
│ │── 注入 userInfo
│←─ {data} ──────────────│
│ │
│── Token 过期 ──────────→│
│ │
│── POST /api/oauth2/ ──→│
│ token │
│ {refresh_token} │── 验证 refresh 类型
│ │── 签发新令牌对
│←─ {new_access_token, ──│
│ new_refresh_token} │
```text
[前端] [后端]
│ │
│── POST /api/login ──────────→│ (公开接口,中间件组外)
│ {username, password, │── 验证码校验(key/code)
│ key, code} │── 密码校验(双重加盐 bcrypt)
│ │── 签发 access_token + refresh_token
│←─ {access_token, ─────────────│
│ refresh_token} │
│ │
│── GET /api/xxx ─────────────→│ (认证接口,auth.jwt 组内)
│ Authorization: │── JwtService::parseToken
│ Bearer <access_token> │── 校验签名 + 有效期 + type=access
│ │── 注入 attributes['userInfo']
│←─ {data} ────────────────────│
│ │
│── access_token 过期 ─────────→│
│── POST /api/refresh ─────────→│ (公开接口)
│ {refresh_token} │── 校验 refresh 类型 + Redis jti(一次性消费)
│←─ {新令牌对} ─────────────────│
```
### Token 设计
| Token 类型 | 有效期 | 用途 | payload.type |
|-----------|--------|------|-------------|
| access_token | 2 小时 | 接口认证 | `access` |
| refresh_token | 7 天 | 刷新令牌 | `refresh` |
| Token 类型 | 默认有效期 | 用途 | 存储 |
|-----------|--------|------|------|
| access_token | 2 小时(`JWT_ACCESS_TTL=7200`) | 接口认证 | 无状态(JWT) |
| refresh_token | 7 天(`JWT_REFRESH_TTL=604800`) | 刷新令牌 | **Redis 有状态**(支持踢下线、改密全端失效、一次性消费) |
### 安全要点
1. **密码错误统一提示**:无论用户不存在还是密码错误,统一提示"用户名或密码错误",避免暴露账号是否存在
1. **密码错误统一提示**:无论用户不存在还是密码错误,统一提示,避免暴露账号是否存在
2. **账号状态校验**:`status != 1` 时禁止登录
3. **Token 类型校验**:access_token 和 refresh_token 通过 `type` 字段区分,不可混用
4. **密钥安全**:JWT 密钥通过 `.env` 配置,生产环境务必修改为强随机字符串
3. **Token 类型校验**:access 与 refresh 通过 `type` 字段区分,不可混用(refresh 令牌不能访问业务接口)
4. **密钥 fail-close**:`JWT_SECRET` 为空或不足 32 字符时 `AppServiceProvider` 抛异常**拒绝启动**——弱密钥无法上线
5. **验证码限流**:按 IP 限流(`CAPTCHA_RATE_WINDOW` / `CAPTCHA_RATE_LIMIT`),万能码仅 `APP_DEBUG=true` 生效
## 二、授权机制
### RBAC 权限模型
### RBAC 权限模型(菜单即权限)
```typescript
```text
┌─────────┐ ┌────────────┐ ┌─────────┐ ┌────────────┐ ┌─────────┐
│ User │───►│ UserRole │───►│ Role │───►│ RoleMenu │───►│ Menu │
│ │ │ │ │ │ │ │ │ │
│ id │ │ user_id │ │ id │ │ role_id │ │ id │
│ username│ │ role_id │ │ name │ │ menu_id │ │ perm │
└─────────┘ └────────────┘ │data_scope│ └────────────┘ │ type │
│ username│ │ role_id │ │ name │ │ menu_id │ │permission│
└─────────┘ └────────────┘ │ code │ └────────────┘ │ type │
└─────────┘ └─────────┘
```
### 权限校验流程
- 菜单表的 `permission` 字段即权限编码(`sys:{模块}:{操作}`)
- `type=1`(菜单)且 `permission` 非空的节点参与接口鉴权
- 角色-菜单变更时 `RoleMenuObserver` / `MenuObserver` 自动清除权限缓存
### 权限校验流程(Authenticate::checkPermission)
```php
// AuthMiddleware::checkPermission()
// app/Http/Middleware/Authenticate.php(节选)
protected function checkPermission(int $uid, string $permissionCode): bool
{
// 1. 超级管理员(uid=1)跳过校验
// 超级管理员跳过
if ($uid === 1) {
return true;
}
// 2. 查询用户角色
$roleIds = UserRole::where('user_id', $uid)->column('role_id');
if (empty($roleIds)) {
return false;
// 从 Redis 获取权限(一级缓存)
try {
$cached = Redis::get("perms:{$uid}");
if ($cached) {
$perms = json_decode($cached, true) ?? [];
// 拥有通配权限或包含目标编码即通过
return in_array('*:*:*', $perms) || in_array($permissionCode, $perms);
}
} catch (\Exception $e) {
// Redis 不可用时继续降级
}
// 3. 通过角色关联获取权限节点
$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) // type=1 为权限节点
->column('m.permission');
// 二级缓存:Laravel Cache(5 分钟)
$cacheKey = "perms_fallback:{$uid}";
$permissions = Cache::remember($cacheKey, 300, function () use ($uid) {
return $this->loadPermissionsFromDb($uid);
});
// 4. 判断是否包含目标权限
return in_array($permissionCode, $permissions);
return in_array('*:*:*', $permissions) || in_array($permissionCode, $permissions);
}
// 数据库加载:user_role → role_menu join menu(未删除、type=1、permission 非空)
protected function loadPermissionsFromDb(int $uid): array
{
$roleIds = UserRole::where('user_id', $uid)->pluck('role_id')->toArray();
if (empty($roleIds)) {
return [];
}
return RoleMenu::join('menu', 'role_menu.menu_id', '=', 'menu.id')
->whereIn('role_menu.role_id', $roleIds)
->where('menu.is_delete', 0)
->where('menu.type', 1)
->where('menu.permission', '!=', '')
->pluck('menu.permission')
->toArray();
}
```
### 数据权限
| data_scope | 范围 | 实现 |
`HasDataScope` 支持三种数据范围(当前 `laravel_role` 表不含 `data_scope` 字段,实际一律按"全部数据"执行):
| data_scope | 范围 | 实现(HasDataScope) |
|-----------|------|------|
| 1 | 全部数据 | 不过滤 |
| 2 | 本部门数据 | WHERE dept_id = 用户部门ID |
| 3 | 仅本人数据 | WHERE create_user = 用户名 |
| 2 | 本部门数据 | `WHERE dept_id = 用户部门ID` |
| 3 | 仅本人数据(默认) | `WHERE create_user = 用户名` |
## 三、防护机制
@@ -190,77 +211,64 @@ protected function checkPermission(int $uid, string $permissionCode): bool
```text
密码存储流程:
原始密码 + 外部 salt(10位随机字符串)
原始密码 + 外部 salt(随机字符串,存 user.salt 字段)
│
▼ password_hash($combined, PASSWORD_BCRYPT, ['cost' => 12])
▼ password_hash($password . $salt, PASSWORD_BCRYPT, ['cost' => 12])
│
▼ bcrypt 内部自动生成 salt 并嵌入哈希
│
▼ 存储:password 字段(60字符)+ salt 字段(10字符)
▼ 存储:password 字段(60 字符哈希)+ salt 字段(独立存储)
密码校验流程:
用户输入密码 + 数据库 salt
│
▼ password_verify($input . $salt, $hashed)
│
▼ true/false
▼ true / false
```
```php
// 加密
$password = '123456';
$salt = 'xK7pMn3qRt'; // 外部盐(10位随机)
$combined = $password . $salt; // 拼接
$hashed = password_hash($combined, PASSWORD_BCRYPT, ['cost' => 12]);
// 结果:$2y$12$...(60字符)
- 成本因子固定为 12(`PasswordService::BCRYPT_ROUNDS`,对应 `.env` 的 `BCRYPT_ROUNDS=12`)
- **双重加盐**:外部 salt 存独立字段,数据库泄露后仍需同时获取 salt 才能暴力破解;bcrypt 内部 salt 进一步增加彩虹表攻击难度
// 校验
$inputCombined = $userInput . $dbSalt;
$isValid = password_verify($inputCombined, $dbHashed);
```
### 参数校验(FormRequest 入口拦截)
:::tip 为什么双重加盐?
外部 salt 存储在独立字段,即使数据库泄露,攻击者也需要同时获取 salt 才能进行暴力破解。bcrypt 内部 salt 进一步增加彩虹表攻击难度。
:::
### 参数校验(自动拦截)
通过 Logic 层 `$validateClass` 配置,add/update 时自动校验参数格式:
通过控制器方法类型注入 FormRequest,进入业务逻辑前完成格式校验:
```php
class UserValidate extends Validate
class UserRequest extends BaseRequest
{
protected $rule = [
'username' => 'require|unique:user',
'realname' => 'require',
'mobile' => 'mobile',
'email' => 'email',
];
protected $scene = [
'add' => ['username', 'realname', 'mobile', 'email'],
'update' => ['realname', 'mobile', 'email'],
];
public function rules(): array
{
return [
'username' => 'required|string|min:3|max:50',
'realname' => 'required|string|max:50',
'mobile' => 'nullable|max:20',
'email' => 'nullable|email',
];
}
}
```
校验失败直接返回 422,不进入业务逻辑层。
校验失败由 `failedValidation()` 直接返回 `code=1` + 字段错误明细,**不进入控制器方法**;唯一性等业务规则由 Service 层兜底(防止绕过格式校验的组合攻击)。
### SQL 注入防护
ThinkPHP ORM 使用参数绑定,自动防止 SQL 注入:
Eloquent 查询构造器使用参数绑定,自动防止 SQL 注入:
```php
// ✅ ORM 查询(安全,自动参数绑定)
User::where('username', $username)->find();
// 生成:SELECT * FROM think_user WHERE username = ? [$username]
User::where('username', $username)->first();
// 生成:SELECT * FROM laravel_user WHERE username = ? [$username]
// ✅ 原生查询(手动绑定参数)
Db::query("SELECT * FROM think_user WHERE username = ?", [$username]);
DB::select("SELECT * FROM laravel_user WHERE username = ?", [$username]);
// ❌ 危险!字符串拼接(禁止)
Db::query("SELECT * FROM think_user WHERE username = '$username'");
DB::select("SELECT * FROM laravel_user WHERE username = '$username'");
```
查询排序也受白名单保护:前端 `orderField` 必须命中 `pageOrderBy` 与 `orderFieldAllow` 的并集,否则忽略(防排序注入)。
:::warning 安全红线
永远不要将用户输入直接拼接到 SQL 字符串中。即使使用原生 SQL,也必须使用参数绑定(`?` 占位符)。
:::
@@ -270,21 +278,23 @@ Db::query("SELECT * FROM think_user WHERE username = '$username'");
```text
上传文件安全检查链:
│
▼ 1. 文件大小限制(config/file.php → max_size,默认 10MB)
▼ 1. 文件大小限制(config/file.php → FILE_MAX_SIZE,默认 10MB)
│
▼ 2. 文件扩展名白名单(config/file.php → allow_ext)
▼ 2. 文件扩展名白名单(FILE_IMAGE_EXT / FILE_EXT)
│ (svg 已移除:内嵌 JS 导致 XSS 风险)
│
▼ 3. MIME 类型检查(fileinfo 扩展)
▼ 3. MIME 类型真实检测(fileinfo 扩展,_import 等场景强制校验)
│
▼ 4. 生成随机文件名(防止路径遍历攻击)
▼ 4. 存储路径由服务端生成(防止路径遍历)
│
▼ 5. 存储到 Web 根目录之外(或 Nginx 禁止执行 PHP)
▼ 5. Nginx 仅允许入口 index.php 执行 —— 上传目录中的 .php 一律 404
```
```ini
# Nginx 配置:上传目录禁止执行 PHP
location ~* /uploads/.*\.php$ {
deny all;
```nginx
# docker/nginx.conf:只有入口可执行,其余 .php 一律拒绝
location = /index.php { /* FastCGI 转发 */ }
location ~ \.php$ {
return 404;
}
```
@@ -292,98 +302,99 @@ location ~* /uploads/.*\.php$ {
```text
防护措施:
1. 前端:用户输入转义显示(Vue 默认转义 {{ }} 插值)
2. 后端:富文本内容通过 save_content() 处理,过滤危险标签
3. 响应头:Content-Type: application/json(非 text/html)
1. 前端:Vue 模板插值默认转义({{ }}),避免注入
2. 后端:响应强制 Content-Type: application/json(ForceJsonResponse 中间件),
浏览器不会按 HTML 渲染
3. 富文本:字段独立处理链(contentFields),内容过滤与占位符机制
4. 上传白名单移除 svg(内嵌脚本风险)
```
### 演示模式保护
```php
// DemoMiddleware 拦截写操作
// .env: [APP] DEMO = true
// .env: DEMO_MODE=true(读取 config('app.demo_mode'))
// 受保护:POST/PUT/DELETE 请求 → 返回 403
// 放行:GET 请求 + 标注 #[DemoAllow] 的方法
// 受保护:POST/PUT/DELETE/PATCH 请求 → 返回 403
// 放行:GET 请求 + 标注 #[DemoAllow] 注解的方法
```
```php
// 标注 #[DemoAllow] 的方法在演示模式下仍可执行
#[DemoAllow]
public function login(): Json { ... }
public function login(LoginRequest $request): JsonResponse { ... }
```
### CORS 跨域控制
```ini
; .env
; 生产环境:限制具体域名
[CORS]
ALLOW_ORIGIN = https://admin.example.com
# .env
# 开发环境:允许所有(仅限开发)
CORS_ALLOW_ORIGIN=*
; 开发环境:允许所有(仅限开发)
[CORS]
ALLOW_ORIGIN = *
# 生产环境:限制具体域名(多个来源用逗号分隔)
CORS_ALLOW_ORIGIN=https://admin.example.com
```
```php
// CorsMiddleware 处理逻辑:
// 1. OPTIONS 预检请求 → 直接返回 204 + CORS 头
// 2. 普通请求 → 校验 Origin 是否在白名单
// 3. 设置 Access-Control-Allow-Origin / Allow-Methods / Allow-Headers
// Cors 中间件处理逻辑(见 4.2 请求处理链路):
// 1. OPTIONS 预检请求 → 直接返回 204 + CORS 头(不进业务逻辑)
// 2. 凭证模式下不能用 * → 回显具体 Origin + Vary: Origin(防 CDN 缓存串扰)
// 3. 仅 Origin 被允许时才设置其余 CORS 头
```
## 四、审计机制
### 操作日志(think_operation_log)
### 操作日志(laravel_operation_log)
`LogMiddleware` 基于 `#[Log]` 注解自动记录所有写操作:
`LogOperation` 中间件基于 `#[OperationLog]` 注解自动记录:
```php
#[Log('用户管理-新增记录', Log::TYPE_ADD, '新增用户:{username}')]
public function add(): Json { ... }
#[OperationLog('用户管理-新增', OperationLog::TYPE_ADD, '新增用户:{username}')]
public function add(UserRequest $request): JsonResponse { ... }
```
**记录字段:**
| 字段 | 说明 | 示例 |
|------|------|------|
| `title` | 操作标题(来自注解) | 用户管理-新增记录 |
| `type` | 操作类型(常量) | 1=新增 2=修改 3=删除 4=查询 5=导入 6=导出 |
| `title` | 操作标题(来自注解,无注解回退请求路径) | 用户管理-新增 |
| `type` | 操作类型(30 种常量,无注解按 HTTP 方法推断) | 1=新增 2=修改 3=删除 4=查询 5=导入 6=导出 |
| `method` | HTTP 方法 | POST |
| `request_method` | 控制器方法名 | add |
| `url` | 请求路径 | /api/user/add |
| `params` | 请求参数(JSON) | {"username":"admin"} |
| `ip` | 客户端 IP | 192.168.1.100 |
| `location` | IP 归属地 | 江苏省南京市 |
| `os` | 操作系统 | Windows 10 |
| `browser` | 浏览器 | Chrome 120 |
| `username` | 操作人 | admin |
| `status` | 执行状态 | 0=成功 1=失败 |
| `error_msg` | 错误信息(失败时) | 用户名已存在 |
| `duration` | 执行耗时(ms) | 125 |
| `create_time` | 操作时间 | 2026-09-22 10:30:00 |
| `param` | 请求参数(JSON,**排除 password/token**,截断 2000 字) | {"username":"admin"} |
| `result` | 响应内容(截断 2000 字) | {"code":0,...} |
| `ip` / `location` | 客户端 IP / 归属地(ip2region) | 192.168.1.100 / 江苏省南京市 |
| `os` / `browser` | 操作系统 / 浏览器 | Windows 11 / Chrome 128 |
| `create_user` | 操作人(userInfo.username) | admin |
| `status` | 执行状态(code=0/200 → 0,其余 → 1) | 0=成功 1=失败 |
| `error` | 错误信息(失败时取 msg) | 名称已存在 |
| `consume_time` | 执行耗时(ms) | 125 |
**记录规则:**
- 写操作(POST/PUT/DELETE)始终记录
- GET 查询操作仅在 `#[Log]` 指定 `TYPE_QUERY` 时记录
- 排除列表:`config/middleware.php` → `log_except` 中的路径不记录
### 登录日志(think_login_log)
- 写操作(POST/PUT/DELETE/PATCH)始终记录
- GET 仅在注解类型为 `EXPORT / DOWNLOAD / IMPORT` 时记录
- `handle` 阶段采集、`terminate` 阶段落库(响应后执行,不阻塞业务;写入失败静默)
### 登录日志(laravel_login_log)
```php
// LoginLogic 中独立记录
$requestInfo = RequestInfoService::create(request());
LoginLog::create([
// AuthController::writeLoginLog(节选)—— 成功与失败均记录
$data = [
'username' => $username,
'type' => 1, // 1=登录 2=登出
'status' => $success ? 0 : 1, // 0=成功 1=失败
'message' => $msg,
'ip' => $requestInfo->getIp(),
'log_type' => 1, // 1=登录 2=登出
'source' => 0, // 来源端:0=后台
'method' => $request->method(),
'request_method' => $entry, // login / refresh 等入口
'url' => $request->fullUrl(),
'param' => json_encode(['username' => $username], ...), // 只记录用户名
'result' => json_encode(['code' => $success ? 0 : 1, 'msg' => $message], ...),
'ip' => $requestInfo->getIp(),
'location' => $requestInfo->getIpLocation(),
'os' => $requestInfo->getOs(),
'browser' => $requestInfo->getBrowser(),
]);
// + os / browser / 耗时
];
```
**记录字段:**
@@ -391,22 +402,19 @@ LoginLog::create([
| 字段 | 说明 |
|------|------|
| `username` | 登录用户名(无论成功失败都记录) |
| `type` | 1=登录 2=登出 |
| `status` | 0=成功 1=失败 |
| `message` | 结果消息(如"登录成功"、"密码错误") |
| `ip` | 客户端 IP |
| `location` | IP 归属地 |
| `os` | 操作系统 |
| `browser` | 浏览器 |
| `log_type` | 1=登录 2=登出 |
| `status` / `result` | 0=成功 1=失败 + 结果消息 |
| `ip` / `location` | 客户端 IP / 归属地 |
| `os` / `browser` | 操作系统 / 浏览器 |
### 日志安全要点
| 要点 | 说明 |
|------|------|
| 密码不记录 | 登录日志不记录密码字段 |
| 参数可脱敏 | 敏感字段建议在 Controller 层处理后再传入 Logic |
| 密码与令牌不记录 | 操作日志 `param` 排除 `password`、`token`;登录日志只记录用户名 |
| 参数截断 | `param` / `result` 截断 2000 字,防止超长内容撑爆字段 |
| 独立存储 | 操作日志和登录日志分别存储在不同表 |
| 不可篡改 | 日志仅 INSERT,无 UPDATE/DELETE 接口 |
| 失败不阻断 | 日志写入异常静默处理,不影响主业务 |
## 五、安全配置清单
@@ -414,38 +422,36 @@ LoginLog::create([
| 配置项 | 开发环境 | 生产环境 | 说明 |
|--------|---------|---------|------|
| `APP_DEBUG` | `true` | **`false`** | 关闭调试模式,隐藏详细错误 |
| `SHOW_ERROR_MSG` | `true` | **`false`** | 隐藏原始错误信息 |
| `JWT.SECRET` | 默认值 | **强随机密钥** | 32 字节以上随机字符串 |
| `CORS.ALLOW_ORIGIN` | `*` | **指定域名** | 如 `https://admin.example.com` |
| `APP.DEMO` | `false` | `false` | 演示环境按需开启 |
| `FILE.MAX_SIZE` | 10MB | 合理限制 | 防止大文件 DoS |
| `API.CAMEL_SNAKE_CONVERT` | `true` | `true` | 驼峰转换开关 |
| `APP_DEBUG` | `true` | **`false`** | 关闭调试模式,异常不返回真实消息 |
| `JWT_SECRET` | 强随机 | **强随机(≥32 字符)** | 不足 32 字符**拒绝启动**(fail-close) |
| `CORS_ALLOW_ORIGIN` | `*` | **指定域名** | 如 `https://admin.example.com` |
| `DEMO_MODE` | `false` | `false` | 演示环境按需开启 |
| `FILE_MAX_SIZE` | 10 | 合理限制 | 防止大文件 DoS |
| `CAPTCHA_BYPASS_CODE` | 可设 | **必须留空** | 万能验证码仅 debug 生效,生产留空 |
| `TRUSTED_PROXIES` | 空 | CDN/负载均衡地址 | 防止伪造代理头获取错误 IP |
| `BCRYPT_ROUNDS` | 12 | 12+ | 密码哈希成本因子 |
| `API_CAMEL_SNAKE_CONVERT` | `true` | `true` | 驼峰转换开关 |
### Nginx 配置
```nginx
# 1. 启用 HTTPS
# 1. 启用 HTTPS(生产必须)
server {
listen 443 ssl;
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
}
# 2. 上传目录禁止执行 PHP
location ~* /uploads/.*\.php$ {
deny all;
}
# 2. 仅入口 PHP 可执行(docker/nginx.conf 已内置)
location = /index.php { /* FastCGI 转发 */ }
location ~ \.php$ { return 404; }
# 3. 禁止访问隐藏文件
location ~ /\. {
deny all;
}
# 3. 禁止访问隐藏文件(.env / .git 等,docker/nginx.conf 已内置)
location ~ /\. { deny all; }
# 4. 安全响应头
# 4. 安全响应头(按需追加)
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy strict-origin-when-cross-origin;
```
@@ -455,18 +461,18 @@ add_header Referrer-Policy strict-origin-when-cross-origin;
上线前逐项检查:
□ .env 中 APP_DEBUG = false
□ .env 中 SHOW_ERROR_MSG = false
□ .env 中 JWT.SECRET 已更换为强随机密钥
□ .env 中 CORS.ALLOW_ORIGIN 指定具体域名
□ .env 中 JWT_SECRET 已更换为 ≥32 字符强随机密钥(否则应用拒绝启动)
□ .env 中 CORS_ALLOW_ORIGIN 指定具体域名
□ .env 中 CAPTCHA_BYPASS_CODE 留空
□ Nginx 已配置 HTTPS
□ Nginx 上传目录已禁止执行 PHP
□ 数据库密码为强密码
□ Redis 已设置密码(如使用)
□ 默认管理员密码已修改
□ Nginx 确认仅入口 index.php 可执行、隐藏文件已拒绝
□ 数据库密码为强密码,Redis 已设置 requirepass 并同步 REDIS_PASSWORD
□ 默认管理员密码已修改(admin / 123456)
□ 演示模式已关闭(除非需要)
□ 后端 /api/* 路由确认在认证中间件组内(公开接口仅 login/refresh/captcha/oauth2)
□ 日志中不包含密码等敏感信息
```
## 总结
系统通过认证、授权、防护、审计四层安全架构,实现了从网络层到数据层的纵深防御。JWT 双令牌认证保证无状态安全,RBAC 权限控制细化到按钮级,bcrypt 双重加盐保护密码安全,操作日志实现全链路审计。各层安全机制相互独立又协同配合,确保系统在企业级应用场景下的安全可靠性。
系统通过认证、授权、防护、审计四层安全架构,实现了从网络层到数据层的纵深防御。JWT 双令牌 + Redis 有状态保证认证安全且支持会话管控,RBAC 权限控制细化到按钮级(三级缓存保证性能),bcrypt 双重加盐保护密码安全,操作日志与登录日志实现全链路审计。各层安全机制相互独立又协同配合,确保系统在企业级应用场景下的安全可靠性。