签入版本
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
# 5.18 注解系统总览
|
||||
|
||||
:::tip 概述
|
||||
项目使用 PHP 8 原生注解(Attribute)实现声明式配置,目前有三个注解类,由 AttributeService 通过反射读取。注解标注在控制器方法或类上,中间件在请求处理过程中读取并执行对应逻辑(日志记录、权限校验、演示放行)。
|
||||
:::
|
||||
|
||||
## 注解清单
|
||||
|
||||
| 注解 | 作用域 | 读取方 | 用途 | 必填参数 |
|
||||
|------|--------|--------|------|---------|
|
||||
| `#[Log]` | 方法 | LogMiddleware | 操作日志记录 | title |
|
||||
| `#[Permission]` | 方法/类 | AuthMiddleware | 权限校验 | code |
|
||||
| `#[DemoAllow]` | 方法 | DemoMiddleware | 演示环境放行 | 无 |
|
||||
|
||||
## 注解执行顺序
|
||||
|
||||
```text
|
||||
请求进入
|
||||
│
|
||||
▼
|
||||
CorsMiddleware(全局)
|
||||
│
|
||||
▼
|
||||
AuthMiddleware
|
||||
│ 读取 #[Permission] 注解
|
||||
│ 校验权限码 → 无权限返回 403
|
||||
│
|
||||
▼
|
||||
TenantMiddleware
|
||||
│
|
||||
▼
|
||||
DemoMiddleware
|
||||
│ 读取 #[DemoAllow] 注解
|
||||
│ 演示环境 + 写操作 + 无注解 → 拦截返回 403
|
||||
│
|
||||
▼
|
||||
LogMiddleware
|
||||
│ 读取 #[Log] 注解
|
||||
│ 记录操作日志
|
||||
│
|
||||
▼
|
||||
Controller 方法执行
|
||||
```
|
||||
|
||||
## #[Log] 操作日志注解
|
||||
|
||||
**文件:** `app/attribute/Log.php`
|
||||
**读取方:** LogMiddleware
|
||||
**作用域:** 方法
|
||||
|
||||
### 基本用法
|
||||
|
||||
```php
|
||||
#[Log('用户管理-新增记录', Log::TYPE_ADD, '新增用户:{username}')]
|
||||
// │ │ │
|
||||
// │ │ └── 描述(支持 {param} 占位符)
|
||||
// │ └── 操作类型(40种常量)
|
||||
// └── 操作标题
|
||||
```
|
||||
|
||||
### 参数说明
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `title` | string | ✓ | 操作标题,如"用户管理-新增记录" |
|
||||
| `type` | int | ✗ | 操作类型常量,默认 0(由中间件按 HTTP 方法推断) |
|
||||
| `description` | string | ✗ | 详细描述,支持 `{param}` 占位符 |
|
||||
|
||||
### 使用示例
|
||||
|
||||
```php
|
||||
// 新增
|
||||
#[Log('用户管理-新增记录', Log::TYPE_ADD, '新增用户:{username}')]
|
||||
|
||||
// 修改
|
||||
#[Log('用户管理-修改记录', Log::TYPE_UPDATE, '修改用户ID:{id}')]
|
||||
|
||||
// 删除
|
||||
#[Log('用户管理-删除记录', Log::TYPE_DELETE, '删除用户ID:{id}')]
|
||||
|
||||
// 批量删除
|
||||
#[Log('用户管理-批量删除记录', Log::TYPE_DELETE)]
|
||||
|
||||
// 导出(GET 请求也会记录)
|
||||
#[Log('用户管理-导出数据', Log::TYPE_EXPORT)]
|
||||
|
||||
// 导入
|
||||
#[Log('用户管理-导入数据', Log::TYPE_IMPORT, '通过Excel导入用户数据')]
|
||||
|
||||
// 重置密码
|
||||
#[Log('用户管理-重置密码', Log::TYPE_RESET, '重置用户ID:{id}的密码')]
|
||||
|
||||
// 登录(TYPE_LOGIN)
|
||||
#[Log('系统登录-用户登录', Log::TYPE_LOGIN)]
|
||||
|
||||
// 登出(TYPE_LOGOUT)
|
||||
#[Log('系统登录-用户登出', Log::TYPE_LOGOUT)]
|
||||
|
||||
// 查询(GET 默认不记录,除非是导出/下载/导入类型)
|
||||
#[Log('用户管理-查询分页记录', Log::TYPE_QUERY)]
|
||||
```
|
||||
|
||||
### 占位符替换
|
||||
|
||||
```php
|
||||
#[Log('文章管理-删除记录', Log::TYPE_DELETE, '删除文章ID:{id}')]
|
||||
|
||||
// {id} 会被替换为 $request->param('id') 的值
|
||||
// 实际记录:"删除文章ID:42"
|
||||
```
|
||||
|
||||
### 操作类型常量
|
||||
|
||||
| 常量 | 值 | 说明 | GET 是否记录 |
|
||||
|------|-----|------|-------------|
|
||||
| `TYPE_ADD` | 1 | 新增 | — |
|
||||
| `TYPE_UPDATE` | 2 | 修改 | — |
|
||||
| `TYPE_DELETE` | 3 | 删除 | — |
|
||||
| `TYPE_QUERY` | 4 | 查询 | ✗ |
|
||||
| `TYPE_IMPORT` | 5 | 导入 | ✓ |
|
||||
| `TYPE_EXPORT` | 6 | 导出 | ✓ |
|
||||
| `TYPE_DOWNLOAD` | 7 | 下载 | ✓ |
|
||||
| `TYPE_APPROVE` | 8 | 审批 | — |
|
||||
| `TYPE_REJECT` | 9 | 驳回 | — |
|
||||
| `TYPE_SUBMIT` | 10 | 提交 | — |
|
||||
| `TYPE_WITHDRAW` | 11 | 撤回 | — |
|
||||
| `TYPE_LOGIN` | 21 | 登录 | — |
|
||||
| `TYPE_LOGOUT` | 22 | 登出 | — |
|
||||
| `TYPE_RESET` | 24 | 重置 | — |
|
||||
| `TYPE_OTHER` | 99 | 其他 | — |
|
||||
|
||||
完整常量列表见 `app/attribute/Log.php`(共 40 种)。
|
||||
|
||||
## #[Permission] 权限注解
|
||||
|
||||
**文件:** `app/attribute/Permission.php`
|
||||
**读取方:** AuthMiddleware
|
||||
**作用域:** 方法 + 类(方法优先)
|
||||
|
||||
### 基本用法
|
||||
|
||||
```php
|
||||
#[Permission('sys:user:add', '添加用户')]
|
||||
// │ │
|
||||
// │ └── 权限名称(用于提示和日志)
|
||||
// └── 权限编码
|
||||
```
|
||||
|
||||
### 参数说明
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `code` | string | ✓ | 权限编码,如 `sys:user:add` |
|
||||
| `name` | string | ✗ | 权限名称,用于提示和日志 |
|
||||
|
||||
### 使用示例
|
||||
|
||||
```php
|
||||
// 方法级注解(最常用)
|
||||
#[Permission('sys:user:add', '添加用户')]
|
||||
public function add(): Json { ... }
|
||||
|
||||
#[Permission('sys:user:page', '用户分页')]
|
||||
public function page(): Json { ... }
|
||||
|
||||
// 类级注解(该类所有方法都需此权限)
|
||||
#[Permission('sys:user:manage', '用户管理')]
|
||||
class UserController extends BaseController { ... }
|
||||
```
|
||||
|
||||
### 优先级规则
|
||||
|
||||
```text
|
||||
1. 方法上有 #[Permission] → 使用方法的权限码
|
||||
2. 方法上无 #[Permission],类上有 → 使用类的权限码
|
||||
3. 方法和类都没有 → 跳过权限校验(所有已登录用户可访问)
|
||||
```
|
||||
|
||||
### 超级管理员
|
||||
|
||||
```text
|
||||
uid=1 的用户为超级管理员,跳过所有权限校验。
|
||||
AuthMiddleware 检测到 uid=1 时直接放行,不查询权限表。
|
||||
```
|
||||
|
||||
## #[DemoAllow] 演示放行注解
|
||||
|
||||
**文件:** `app/attribute/DemoAllow.php`
|
||||
**读取方:** DemoMiddleware
|
||||
**作用域:** 方法
|
||||
**参数:** 无
|
||||
|
||||
### 基本用法
|
||||
|
||||
```php
|
||||
// 标注后,演示环境下该写操作不受拦截
|
||||
#[DemoAllow]
|
||||
public function refreshCache(): Json { ... }
|
||||
|
||||
#[DemoAllow]
|
||||
public function login(): Json { ... }
|
||||
```
|
||||
|
||||
### 工作原理
|
||||
|
||||
```text
|
||||
DemoMiddleware 检查逻辑:
|
||||
1. APP_DEMO = false → 放行(非演示环境,env('app_demo'))
|
||||
2. APP_DEMO = true + GET 请求 → 放行
|
||||
3. APP_DEMO = true + 写操作(POST/PUT/DELETE)
|
||||
├─ 方法有 #[DemoAllow] → 放行
|
||||
└─ 方法无 #[DemoAllow] → 拦截,返回 403
|
||||
```
|
||||
|
||||
### 典型场景
|
||||
|
||||
```php
|
||||
// 登录接口:演示环境也需要允许登录
|
||||
#[DemoAllow]
|
||||
public function login(): Json { ... }
|
||||
|
||||
// 缓存刷新:演示环境也需要允许
|
||||
#[DemoAllow]
|
||||
public function refreshCache(): Json { ... }
|
||||
|
||||
// 用户新增:演示环境禁止(无 #[DemoAllow])
|
||||
public function add(): Json { ... }
|
||||
```
|
||||
|
||||
## 注解组合使用
|
||||
|
||||
```php
|
||||
// 操作日志 + 权限校验(最常见组合)
|
||||
#[Log('用户管理-新增记录', Log::TYPE_ADD, '新增用户:{username}')]
|
||||
#[Permission('sys:user:add', '添加用户')]
|
||||
public function add(): Json { ... }
|
||||
|
||||
// 操作日志 + 权限校验 + 演示放行
|
||||
#[Log('系统登录-用户登录', Log::TYPE_LOGIN)]
|
||||
#[Permission('sys:login:do', '登录')]
|
||||
#[DemoAllow]
|
||||
public function login(): Json { ... }
|
||||
|
||||
// 仅权限校验(查询接口不需要日志)
|
||||
#[Permission('sys:user:page', '用户分页')]
|
||||
public function page(): Json { ... }
|
||||
|
||||
// 仅日志(不需要权限校验的公开接口)
|
||||
#[Log('系统登录-获取验证码', Log::TYPE_OTHER)]
|
||||
public function captcha(): Json { ... }
|
||||
```
|
||||
|
||||
## AttributeService
|
||||
|
||||
**文件:** `app/service/AttributeService.php`
|
||||
**职责:** 通过反射读取注解,带缓存机制
|
||||
|
||||
### 方法清单
|
||||
|
||||
| 方法 | 说明 | 返回值 |
|
||||
|------|------|--------|
|
||||
| `getLog($class, $method)` | 获取方法上的 Log 注解 | `Log\|null` |
|
||||
| `getPermission($class, $method)` | 获取方法/类上的 Permission 注解 | `Permission\|null` |
|
||||
| `hasDemoAllow($class, $method)` | 判断方法是否有 DemoAllow 注解 | `bool` |
|
||||
|
||||
### 使用示例
|
||||
|
||||
```php
|
||||
// 获取方法上的 Log 注解
|
||||
$log = AttributeService::getLog('app\controller\UserController', 'add');
|
||||
if ($log) {
|
||||
$title = $log->title; // '用户管理-新增记录'
|
||||
$type = $log->type; // 1 (TYPE_ADD)
|
||||
$desc = $log->description; // '新增用户:{username}'
|
||||
}
|
||||
|
||||
// 获取方法上的 Permission 注解(方法优先,其次类)
|
||||
$perm = AttributeService::getPermission('app\controller\UserController', 'add');
|
||||
if ($perm) {
|
||||
$code = $perm->code; // 'sys:user:add'
|
||||
$name = $perm->name; // '添加用户'
|
||||
}
|
||||
|
||||
// 判断是否有 DemoAllow 注解
|
||||
$allowed = AttributeService::hasDemoAllow('app\controller\DictController', 'refreshCache');
|
||||
// true / false
|
||||
```
|
||||
|
||||
### 缓存机制
|
||||
|
||||
```text
|
||||
缓存键:类名@方法名
|
||||
缓存位置:static::$localCache(请求级缓存)
|
||||
缓存策略:同一请求内不重复反射,跨请求重新解析
|
||||
|
||||
示例:
|
||||
"app\controller\UserController@add" → Log 实例 + Permission 实例
|
||||
```
|
||||
|
||||
## 创建自定义注解
|
||||
|
||||
```php
|
||||
// 1. 定义注解类
|
||||
// app/attribute/RateLimit.php
|
||||
namespace app\attribute;
|
||||
|
||||
use Attribute;
|
||||
|
||||
#[Attribute(Attribute::TARGET_METHOD)]
|
||||
class RateLimit
|
||||
{
|
||||
public function __construct(
|
||||
public int $max = 100, // 最大请求数
|
||||
public int $window = 60, // 窗口期(秒)
|
||||
) {}
|
||||
}
|
||||
|
||||
// 2. 在控制器方法上使用
|
||||
#[RateLimit(max: 10, window: 60)]
|
||||
public function sendSms(): Json { ... }
|
||||
|
||||
// 3. 在中间件中读取
|
||||
public function handle($request, \Closure $next)
|
||||
{
|
||||
$ref = new \ReflectionMethod($controller, $method);
|
||||
$attrs = $ref->getAttributes(RateLimit::class);
|
||||
if (!empty($attrs)) {
|
||||
$rateLimit = $attrs[0]->newInstance();
|
||||
// $rateLimit->max, $rateLimit->window
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
| 实践 | 说明 |
|
||||
|------|------|
|
||||
| 注解即文档 | 通过注解即可了解接口的操作类型、权限要求 |
|
||||
| 写操作必须标注 `#[Log]` | 删除、重置密码等敏感操作必须记录日志 |
|
||||
| 权限码统一格式 | 使用 `sys:{module}:{action}` 格式 |
|
||||
| 描述加关键参数 | 用 `{param}` 占位符记录业务主键,便于追溯 |
|
||||
| 查询不标注 `#[Log]` | 普通查询不记录日志,避免日志膨胀 |
|
||||
| 组合使用 | `#[Log]` + `#[Permission]` 组合是最常见的模式 |
|
||||
@@ -0,0 +1,488 @@
|
||||
# 5.2 认证流程实战
|
||||
|
||||
:::tip 概述
|
||||
本节通过完整的请求链路,展示从登录到接口调用的认证全流程,包括登录、Token 刷新、接口认证、权限校验、租户隔离五个核心环节。
|
||||
:::
|
||||
|
||||
## 认证全景图
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 认证授权全景 │
|
||||
│ │
|
||||
│ ① 登录 POST /api/login │
|
||||
│ 用户名+密码+验证码 → 校验 → 签发 access_token + refresh_token │
|
||||
│ │
|
||||
│ ② 请求 API GET/POST /api/xxx │
|
||||
│ Bearer token → AuthMiddleware → 权限校验 → 注入 userInfo │
|
||||
│ │
|
||||
│ ③ Token 过期 响应 code=401 │
|
||||
│ refresh_token → POST /api/oauth2/token → 新令牌对 │
|
||||
│ │
|
||||
│ ④ 租户隔离 TenantMiddleware │
|
||||
│ 解析租户 → 注入 tenantId → Logic 层自动过滤 │
|
||||
│ │
|
||||
│ ⑤ 登出 前端清除 localStorage │
|
||||
│ (JWT 无状态,服务端无需处理) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 一、登录流程
|
||||
|
||||
### 完整流程图
|
||||
|
||||
```text
|
||||
POST /api/login
|
||||
{ "username": "admin", "password": "123456", "code": "a3Bx", "key": "xxx" }
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ LoginController::login() │
|
||||
│ │
|
||||
│ 1. getParams() 获取请求参数 │
|
||||
│ 2. validate($params, LoginValidate::class . '.login') │
|
||||
│ │ 失败 → {"code":422, "msg":"参数验证失败"} │
|
||||
│ ▼ │
|
||||
│ 3. LoginLogic::login($username, $password, $code, $key) │
|
||||
└─────────────────────────┬───────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ LoginLogic::login() │
|
||||
│ │
|
||||
│ 1. CaptchaService::check($code, $key) │
|
||||
│ │ 失败 → throw "验证码错误" │
|
||||
│ ▼ │
|
||||
│ 2. JwtService::login($username, $password) │
|
||||
└─────────────────────────┬───────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ JwtService::login() │
|
||||
│ │
|
||||
│ 1. User::withoutGlobalScope(['soft_delete']) │
|
||||
│ ->where('username', $username)->find() │
|
||||
│ │ 不存在 → throw "用户名或密码错误" │
|
||||
│ ▼ │
|
||||
│ 2. $user->status != 1 │
|
||||
│ │ 禁用 → throw "账号已被禁用" │
|
||||
│ ▼ │
|
||||
│ 3. PasswordService::verify($password, $hashed, $salt) │
|
||||
│ │ 不匹配 → throw "用户名或密码错误" │
|
||||
│ ▼ │
|
||||
│ 4. Jwt::createAccessToken($uid, $username) │
|
||||
│ Jwt::createRefreshToken($uid, $username) │
|
||||
│ ▼ │
|
||||
│ 5. 返回 { access_token, refresh_token, user } │
|
||||
└─────────────────────────┬───────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ LoginController 续 │
|
||||
│ │
|
||||
│ 成功 → LoginLogic::recordLoginLog(成功) │
|
||||
│ → $this->success($result, '登录成功', true) │
|
||||
│ │
|
||||
│ 失败 → LoginLogic::recordLoginLog(失败) │
|
||||
│ → $this->fail($e->getMessage()) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 登录接口详情
|
||||
|
||||
**请求:**
|
||||
|
||||
```bash
|
||||
POST /api/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "123456",
|
||||
"code": "a3Bx",
|
||||
"key": "captcha_xxx"
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"msg": "登录成功",
|
||||
"data": {
|
||||
"accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
||||
"refreshToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
||||
"tokenType": "Bearer",
|
||||
"expiresIn": 7200,
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"realname": "管理员",
|
||||
"avatar": "/uploads/user/avatar/avatar.jpg"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**失败响应:**
|
||||
|
||||
| 场景 | code | msg |
|
||||
|------|------|-----|
|
||||
| 验证码错误 | 1 | 验证码错误 |
|
||||
| 用户不存在 | 1 | 用户名或密码错误 |
|
||||
| 密码错误 | 1 | 用户名或密码错误 |
|
||||
| 账号禁用 | 1 | 账号已被禁用 |
|
||||
| 参数缺失 | 422 | 用户名不能为空 / 密码不能为空 |
|
||||
|
||||
## 二、接口认证流程
|
||||
|
||||
### 中间件链执行顺序
|
||||
|
||||
```text
|
||||
请求 GET /api/user/page
|
||||
Authorization: Bearer eyJ...
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ CorsMiddleware(全局) │
|
||||
│ OPTIONS 预检 → 返回 204 + CORS 头 │
|
||||
│ 普通请求 → 设置跨域头,放行 │
|
||||
└─────────────────────────┬───────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ AuthMiddleware │
|
||||
│ │
|
||||
│ 1. 检查排除列表(login/captcha/oauth2 → 直接放行) │
|
||||
│ 2. Jwt::getTokenFromHeader() │
|
||||
│ │ 空 → {"code":401, "msg":"未提供认证令牌"} │
|
||||
│ ▼ │
|
||||
│ 3. Jwt::decode($token) │
|
||||
│ │ 过期 → {"code":401, "msg":"token已过期"} │
|
||||
│ │ 签名无效 → {"code":401, "msg":"token签名无效"} │
|
||||
│ ▼ │
|
||||
│ 4. $decoded->type === 'access' │
|
||||
│ │ 非 access → {"code":401, "msg":"无效的认证令牌"} │
|
||||
│ ▼ │
|
||||
│ 5. $request->userInfo = $decoded │
|
||||
│ │ 注入 uid、username 等到请求对象 │
|
||||
│ ▼ │
|
||||
│ 6. 读取 #[Permission] 注解 │
|
||||
│ │ uid=1 → 超级管理员,跳过校验 │
|
||||
│ │ 查询用户权限列表 → 匹配权限码 │
|
||||
│ │ 无权限 → {"code":403, "msg":"无访问权限"} │
|
||||
│ ▼ │
|
||||
│ 7. 放行 │
|
||||
└─────────────────────────┬───────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ TenantMiddleware │
|
||||
│ │
|
||||
│ 1. 从 JWT payload 获取 uid │
|
||||
│ 2. 查询用户所属租户 │
|
||||
│ 3. $request->tenantId = 租户ID │
|
||||
│ 4. $request->tenantInfo = 租户信息 │
|
||||
│ │ 租户禁用/过期 → {"code":403, "msg":"租户异常"} │
|
||||
│ ▼ │
|
||||
│ 5. 放行 │
|
||||
└─────────────────────────┬───────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ DemoMiddleware │
|
||||
│ │
|
||||
│ 1. 检查 DEMO 配置(env('app_demo')) │
|
||||
│ 2. 非演示模式 → 放行 │
|
||||
│ 3. 演示模式 + 写操作(POST/PUT/DELETE) │
|
||||
│ │ 未标注 #[DemoAllow] → {"code":403, "msg":"演示环境"} │
|
||||
│ ▼ │
|
||||
│ 4. 放行 │
|
||||
└─────────────────────────┬───────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ LogMiddleware │
|
||||
│ │
|
||||
│ 1. 读取 #[Log] 注解 │
|
||||
│ 2. 记录操作日志(异步/同步) │
|
||||
│ 3. 放行 │
|
||||
└─────────────────────────┬───────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ UserController::page() │
|
||||
│ │
|
||||
│ return parent::_index($this->logic); │
|
||||
│ → Logic::pageList() → Model → Database │
|
||||
└─────────────────────────┬───────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
JSON 响应返回
|
||||
```
|
||||
|
||||
### 权限校验详解
|
||||
|
||||
```text
|
||||
AuthMiddleware::checkPermission($uid, $permissionCode)
|
||||
│
|
||||
▼
|
||||
1. $uid === 1(超级管理员)
|
||||
│ 是 → return true(跳过校验)
|
||||
▼
|
||||
2. UserRole::where('user_id', $uid)->column('role_id')
|
||||
│ 无角色 → return false
|
||||
▼
|
||||
3. RoleMenu → Menu::whereIn('role_id', $roleIds)
|
||||
.where('type', 1) // type=1 为权限节点
|
||||
.column('permission')
|
||||
▼
|
||||
4. in_array($permissionCode, $permissions)
|
||||
│ 包含 → return true(放行)
|
||||
│ 不包含 → return false(返回 403)
|
||||
```
|
||||
|
||||
**权限码格式:** `sys:{模块名}:{操作}`
|
||||
|
||||
```text
|
||||
sys:user:add 添加用户
|
||||
sys:user:update 修改用户
|
||||
sys:user:delete 删除用户
|
||||
sys:user:page 用户分页
|
||||
sys:article:add 添加文章
|
||||
sys:article:list 文章列表
|
||||
```
|
||||
|
||||
## 三、Token 刷新流程
|
||||
|
||||
### 流程图
|
||||
|
||||
```text
|
||||
前端检测到 code=401
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 前端响应拦截器 │
|
||||
│ │
|
||||
│ 1. 从 localStorage 读取 refresh_token │
|
||||
│ 2. POST /api/oauth2/token │
|
||||
│ { grant_type: "refresh_token", refresh_token: "..." } │
|
||||
└─────────────────────────┬───────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ LoginController::token() │
|
||||
│ │
|
||||
│ 1. 判断 grant_type === 'refresh_token' │
|
||||
│ 2. LoginLogic::refreshToken($refreshToken) │
|
||||
└─────────────────────────┬───────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ JwtService::refreshToken() │
|
||||
│ │
|
||||
│ 1. Jwt::decode($refreshToken) │
|
||||
│ │ 无效 → throw "无效的refresh_token" │
|
||||
│ ▼ │
|
||||
│ 2. $decoded->type === 'refresh' │
|
||||
│ │ 非 refresh → throw "无效的refresh_token" │
|
||||
│ ▼ │
|
||||
│ 3. User::find($decoded->uid) │
|
||||
│ │ 不存在或禁用 → throw "用户不存在或已被禁用" │
|
||||
│ ▼ │
|
||||
│ 4. 签发新 access_token + 新 refresh_token │
|
||||
│ ▼ │
|
||||
│ 5. 返回新令牌对 │
|
||||
└─────────────────────────┬───────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 前端响应拦截器(续) │
|
||||
│ │
|
||||
│ 1. 更新 localStorage 中的 access_token │
|
||||
│ 2. 更新 localStorage 中的 refresh_token │
|
||||
│ 3. 用新 access_token 重试原失败请求 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 刷新接口详情
|
||||
|
||||
**请求:**
|
||||
|
||||
```bash
|
||||
POST /api/oauth2/token
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"accessToken": "eyJ...(新access_token)",
|
||||
"refreshToken": "eyJ...(新refresh_token)",
|
||||
"tokenType": "Bearer",
|
||||
"expiresIn": 7200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**失败响应:**
|
||||
|
||||
| 场景 | code | msg |
|
||||
|------|------|-----|
|
||||
| 缺少 refresh_token | 1 | 缺少refresh_token参数 |
|
||||
| refresh_token 无效/过期 | 1 | 无效的refresh_token |
|
||||
| 用户不存在/禁用 | 1 | 用户不存在或已被禁用 |
|
||||
|
||||
## 四、完整请求示例
|
||||
|
||||
以"查询用户分页"为例,展示一次认证请求的完整链路:
|
||||
|
||||
```text
|
||||
① 前端发起请求
|
||||
GET /api/user/page?pageNo=1&pageSize=20
|
||||
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
|
||||
│
|
||||
▼
|
||||
② CorsMiddleware → 设置跨域头,放行
|
||||
│
|
||||
▼
|
||||
③ AuthMiddleware
|
||||
│ getTokenFromHeader() → "eyJ..."
|
||||
│ Jwt::decode() → { uid:1, username:"admin", type:"access", exp:... }
|
||||
│ type === 'access' ✓
|
||||
│ $request->userInfo = $decoded
|
||||
│ checkPermission(1, 'sys:user:page')
|
||||
│ uid=1 → 超级管理员,跳过
|
||||
▼
|
||||
④ TenantMiddleware
|
||||
│ 查询用户租户 → tenantId=1
|
||||
│ $request->tenantId = 1
|
||||
▼
|
||||
⑤ DemoMiddleware → 非演示模式,放行
|
||||
│
|
||||
▼
|
||||
⑥ LogMiddleware → GET 查询不记录日志,放行
|
||||
│
|
||||
▼
|
||||
⑦ UserController::page()
|
||||
│ return parent::_index($this->logic)
|
||||
▼
|
||||
⑧ UserLogic::pageList($params)
|
||||
│ applyTenantScope() → WHERE tenant_id = 1
|
||||
│ applyDataScope() → 按角色 data_scope 过滤
|
||||
│ → SELECT * FROM think_user WHERE is_delete=0 AND tenant_id=1 ...
|
||||
▼
|
||||
⑨ 返回 JSON
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"records": [...],
|
||||
"total": 42,
|
||||
"size": 20,
|
||||
"current": 1,
|
||||
"pages": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 五、前端自动刷新 Token 实现
|
||||
|
||||
```typescript
|
||||
// src/utils/http/axios/index.ts
|
||||
|
||||
let isRefreshing = false;
|
||||
let pendingRequests: Function[] = [];
|
||||
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
async error => {
|
||||
const { config, response } = error;
|
||||
|
||||
// 非 401 错误直接抛出
|
||||
if (response?.status !== 401) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 已在刷新中,将请求加入队列等待
|
||||
if (isRefreshing) {
|
||||
return new Promise(resolve => {
|
||||
pendingRequests.push((newToken: string) => {
|
||||
config.headers['Authorization'] = `Bearer ${newToken}`;
|
||||
resolve(axios(config));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
config._retry = true;
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
const res = await refreshTokenApi(refreshToken);
|
||||
const { accessToken, refreshToken: newRefreshToken } = res.data;
|
||||
|
||||
// 更新存储
|
||||
localStorage.setItem('access_token', accessToken);
|
||||
localStorage.setItem('refresh_token', newRefreshToken);
|
||||
|
||||
// 重试当前请求
|
||||
config.headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
|
||||
// 执行队列中的等待请求
|
||||
pendingRequests.forEach(cb => cb(accessToken));
|
||||
pendingRequests = [];
|
||||
|
||||
return axios(config);
|
||||
} catch (e) {
|
||||
// 刷新失败,清除登录态,跳转登录页
|
||||
localStorage.clear();
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(e);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## 六、OAuth2 兼容接口
|
||||
|
||||
系统同时提供 OAuth2 风格的 Token 接口,兼容标准 OAuth2 客户端:
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/oauth2/token` | POST | 获取令牌(grant_type=password 或 refresh_token) |
|
||||
| `/api/oauth2/remove/{token}` | DELETE | 吊销令牌 |
|
||||
| `/api/oauth2/check/token` | GET | 检测令牌有效性 |
|
||||
|
||||
### 密码模式获取令牌
|
||||
|
||||
```bash
|
||||
POST /api/oauth2/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=password&username=admin&password=123456
|
||||
```
|
||||
|
||||
### 刷新模式获取令牌
|
||||
|
||||
```bash
|
||||
POST /api/oauth2/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=refresh_token&refresh_token=eyJ...
|
||||
```
|
||||
@@ -0,0 +1,480 @@
|
||||
# 5.3 验证码机制
|
||||
|
||||
:::tip 概述
|
||||
CaptchaService 提供图片验证码的生成与验证,用于登录等需要人机校验的场景。验证码基于 PHP GD 库生成,采用 6 位字母数字混合字符(去除易混淆字符 0O、1lI),支持防刷限流和万能验证码。
|
||||
:::
|
||||
|
||||
## 工作流程
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 获取验证码 │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
前端 后端
|
||||
│ │
|
||||
│── GET /api/captcha ──────────────────►│
|
||||
│ ?clientIp=192.168.1.1 │
|
||||
│ │
|
||||
│ ┌────────┴────────┐
|
||||
│ │ CaptchaService │
|
||||
│ │ ::generate() │
|
||||
│ └────────┬────────┘
|
||||
│ │
|
||||
│ ┌────────┴────────┐
|
||||
│ │ 1. 防刷检查 │
|
||||
│ │ checkRateLimit() │
|
||||
│ │ 超限 → 返回等待 │
|
||||
│ └────────┬────────┘
|
||||
│ │
|
||||
│ ┌────────┴────────┐
|
||||
│ │ 2. 生成随机码 │
|
||||
│ │ 6位 去混淆字符 │
|
||||
│ └────────┬────────┘
|
||||
│ │
|
||||
│ ┌────────┴────────┐
|
||||
│ │ 3. 生成 UUID key │
|
||||
│ └────────┬────────┘
|
||||
│ │
|
||||
│ ┌────────┴────────┐
|
||||
│ │ 4. 存入 Redis │
|
||||
│ │ captcha:{key} │
|
||||
│ │ TTL = 300秒 │
|
||||
│ └────────┬────────┘
|
||||
│ │
|
||||
│ ┌────────┴────────┐
|
||||
│ │ 5. GD 库渲染图片 │
|
||||
│ │ 干扰线 + 噪点 │
|
||||
│ │ → Base64 编码 │
|
||||
│ └────────┬────────┘
|
||||
│ │
|
||||
│◄── { key, captcha } ─────────────────│
|
||||
│ │
|
||||
│ 显示 Base64 图片 │
|
||||
│ 用户输入验证码 │
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 登录时验证 │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
前端 后端
|
||||
│ │
|
||||
│── POST /api/login ──────────────────►│
|
||||
│ { username, password, │
|
||||
│ code: "AB3K7M", │
|
||||
│ key: "550e8400-..." } │
|
||||
│ │
|
||||
│ ┌────────┴────────┐
|
||||
│ │ CaptchaService │
|
||||
│ │ ::verify() │
|
||||
│ └────────┬────────┘
|
||||
│ │
|
||||
│ ┌────────┴────────┐
|
||||
│ │ 1. 万能验证码? │
|
||||
│ │ 匹配 → 直接通过 │
|
||||
│ └────────┬────────┘
|
||||
│ │
|
||||
│ ┌────────┴────────┐
|
||||
│ │ 2. Redis 查询 │
|
||||
│ │ captcha:{key} │
|
||||
│ │ 不存在 → 已过期 │
|
||||
│ └────────┬────────┘
|
||||
│ │
|
||||
│ ┌────────┴────────┐
|
||||
│ │ 3. 比较验证码 │
|
||||
│ │ 大小写不敏感 │
|
||||
│ └────────┬────────┘
|
||||
│ │
|
||||
│ ┌────────┴────────┐
|
||||
│ │ 4. 删除缓存 │
|
||||
│ │ 一次性使用 │
|
||||
│ └────────┬────────┘
|
||||
│ │
|
||||
│ ┌────────┴────────┐
|
||||
│ │ 5. 继续登录流程 │
|
||||
│ │ JwtService:: │
|
||||
│ │ login() │
|
||||
│ └────────┬────────┘
|
||||
│ │
|
||||
│◄── { access_token, ... } ────────────│
|
||||
```
|
||||
|
||||
## 接口说明
|
||||
|
||||
### 获取验证码
|
||||
|
||||
```bash
|
||||
GET /api/captcha
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"key": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"captcha": "data:image/png;base64,iVBOR..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `key`:验证码唯一标识(UUID v4 格式),登录时需回传
|
||||
- `captcha`:Base64 编码的图片,可直接用于 `<img src="...">`
|
||||
|
||||
### 登录时验证
|
||||
|
||||
```bash
|
||||
POST /api/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "123456",
|
||||
"code": "AB3K7M",
|
||||
"key": "550e8400-e29b-41d4-a716-446655440000"
|
||||
}
|
||||
```
|
||||
|
||||
### 错误响应
|
||||
|
||||
| 场景 | code | msg |
|
||||
|------|------|-----|
|
||||
| 验证码过期/不存在 | 1 | 验证码错误 |
|
||||
| 验证码不匹配 | 1 | 验证码错误 |
|
||||
| 获取频率超限 | 1 | 请求过于频繁,请等待 N 秒 |
|
||||
|
||||
:::tip 验证码错误统一提示
|
||||
无论验证码是过期、不存在还是不匹配,统一提示"验证码错误",不暴露具体原因,防止攻击者探测。
|
||||
:::
|
||||
|
||||
### Redis 存储结构
|
||||
|
||||
```text
|
||||
Redis Key 格式:
|
||||
|
||||
captcha:{uuid-key}
|
||||
值:验证码小写(如 "ab3k7m")
|
||||
TTL:300 秒(5分钟)
|
||||
|
||||
captcha_rate:{client-ip}
|
||||
值:请求次数计数
|
||||
TTL:60 秒(窗口期)
|
||||
```
|
||||
|
||||
```text
|
||||
示例:
|
||||
captcha:550e8400-e29b-41d4-a716-446655440000 → "ab3k7m" (TTL 300s)
|
||||
captcha_rate:192.168.1.1 → "3" (TTL 60s)
|
||||
```
|
||||
|
||||
## 核心代码
|
||||
|
||||
```php
|
||||
// app/service/CaptchaService.php
|
||||
|
||||
class CaptchaService
|
||||
{
|
||||
/**
|
||||
* 生成验证码
|
||||
*
|
||||
* @return array ['key' => 'UUID标识', 'captcha' => 'Base64图片']
|
||||
*/
|
||||
public static function generate(): array
|
||||
{
|
||||
// 1. 从配置读取字符集和长度(默认 6 位,字符集去除易混淆字符 0O、1lI)
|
||||
$length = (int)env('captcha.captcha_length', 6);
|
||||
$chars = env('captcha.captcha_chars', 'ABCDEFGHJKLMNPQRSTUVWXYZ2346789');
|
||||
$expire = (int)env('captcha.captcha_expire', 300);
|
||||
|
||||
// 2. 生成随机字符
|
||||
$code = '';
|
||||
$charsLen = strlen($chars);
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$code .= $chars[random_int(0, $charsLen - 1)];
|
||||
}
|
||||
|
||||
// 3. 生成 UUID v4 格式的唯一标识
|
||||
$key = self::uuid();
|
||||
|
||||
// 4. 存入 Redis(小写存储,5 分钟过期)
|
||||
$redis = self::getRedis();
|
||||
$redis->setex(self::KEY_PREFIX . $key, $expire, strtolower($code));
|
||||
|
||||
// 5. 从配置读取图片尺寸,渲染验证码图片并转为 base64
|
||||
$width = (int)env('captcha.captcha_image_width', 160);
|
||||
$height = (int)env('captcha.captcha_image_height', 50);
|
||||
$imageBase64 = self::renderImage($code, $width, $height);
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'captcha' => $imageBase64,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码防刷检查(滑动窗口计数)
|
||||
*
|
||||
* @param string $clientIp 客户端 IP
|
||||
* @return int 剩余等待秒数,0 表示可继续请求
|
||||
*/
|
||||
public static function checkRateLimit(string $clientIp): int
|
||||
{
|
||||
$window = (int)env('captcha.captcha_rate_window', 60);
|
||||
$limit = (int)env('captcha.captcha_rate_limit', 10);
|
||||
|
||||
$redis = self::getRedis();
|
||||
$rateKey = self::RATE_KEY_PREFIX . $clientIp;
|
||||
$count = (int)$redis->get($rateKey);
|
||||
|
||||
if ($count >= $limit) {
|
||||
$ttl = $redis->ttl($rateKey);
|
||||
return $ttl > 0 ? $ttl : $window;
|
||||
}
|
||||
|
||||
$redis->incr($rateKey);
|
||||
if ($count === 0) {
|
||||
$redis->expire($rateKey, $window);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证验证码(一次性使用)
|
||||
*
|
||||
* @param string $key 验证码 key
|
||||
* @param string $code 用户输入的验证码
|
||||
* @param bool $deleteVerify 验证后是否删除
|
||||
* @return bool 是否验证通过
|
||||
*/
|
||||
public static function verify(string $key, string $code, bool $deleteVerify = true): bool
|
||||
{
|
||||
if (empty($code)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 万能验证码:配置了且匹配则跳过验证
|
||||
$bypassCode = env('captcha.captcha_bypass_code', '');
|
||||
if (!empty($bypassCode) && strtolower($code) === strtolower($bypassCode)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (empty($key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$redis = self::getRedis();
|
||||
$redisKey = self::KEY_PREFIX . $key;
|
||||
$cachedCode = $redis->get($redisKey);
|
||||
|
||||
if ($cachedCode === null || $cachedCode === false) {
|
||||
return false; // 已过期或不存在
|
||||
}
|
||||
|
||||
// 验证后删除,确保一次性使用
|
||||
if ($deleteVerify) {
|
||||
$redis->del($redisKey);
|
||||
}
|
||||
|
||||
// 大小写不敏感比较(Redis 中已存为小写)
|
||||
return strtolower($code) === $cachedCode;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 前端集成
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-form-item>
|
||||
<el-input v-model="form.captchaCode" placeholder="验证码" />
|
||||
<img :src="captchaImage" @click="refreshCaptcha" style="cursor: pointer;" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { getCaptcha } from '@/api/common/user';
|
||||
|
||||
const captchaImage = ref('');
|
||||
const form = ref({ captchaKey: '', captchaCode: '' });
|
||||
|
||||
const refreshCaptcha = async () => {
|
||||
const res = await getCaptcha();
|
||||
form.value.captchaKey = res.key;
|
||||
captchaImage.value = res.captcha; // 注意:返回字段是 captcha,不是 image
|
||||
};
|
||||
|
||||
onMounted(() => refreshCaptcha());
|
||||
</script>
|
||||
```
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
```ini
|
||||
; .env
|
||||
[CAPTCHA]
|
||||
; 验证码长度(默认 6)
|
||||
CAPTCHA_LENGTH = 6
|
||||
; 字符集(默认已去除易混淆字符 0O、1lI)
|
||||
CAPTCHA_CHARS = ABCDEFGHJKLMNPQRSTUVWXYZ2346789
|
||||
; 过期时间,秒(默认 300)
|
||||
CAPTCHA_EXPIRE = 300
|
||||
; 图片宽度(默认 160)
|
||||
CAPTCHA_IMAGE_WIDTH = 160
|
||||
; 图片高度(默认 50)
|
||||
CAPTCHA_IMAGE_HEIGHT = 50
|
||||
; 字体大小(默认 28)
|
||||
CAPTCHA_FONT_SIZE = 28
|
||||
; 干扰线数量(默认 8)
|
||||
CAPTCHA_NOISE_LINES = 8
|
||||
; 噪点数量(默认 500)
|
||||
CAPTCHA_NOISE_DOTS = 500
|
||||
; 万能验证码(为空时不生效,仅开发/调试环境使用)
|
||||
CAPTCHA_BYPASS_CODE =
|
||||
; 防刷窗口期,秒(默认 60)
|
||||
CAPTCHA_RATE_WINDOW = 60
|
||||
; 窗口期内最大请求数(默认 20)
|
||||
CAPTCHA_RATE_LIMIT = 20
|
||||
```
|
||||
|
||||
## 安全特性
|
||||
|
||||
| 特性 | 说明 | 防御目标 |
|
||||
|------|------|---------|
|
||||
| 一次性使用 | 验证后 `$redis->del()` 删除缓存 | 防止重放攻击 |
|
||||
| 过期机制 | 5 分钟 TTL,超时需重新获取 | 防止长时间尝试破解 |
|
||||
| 大小写不敏感 | 存储转小写,验证时小写比较 | 降低用户输入门槛 |
|
||||
| 去除易混淆字符 | 字符集去掉 0O、1lI | 减少输入错误 |
|
||||
| 防刷限流 | 滑动窗口计数,60 秒内最多 10 次 | 防止暴力获取验证码 |
|
||||
| 万能验证码 | `captcha_bypass_code` 配置 | 开发调试便利 |
|
||||
| UUID v4 key | 验证码标识不可预测 | 防止枚举攻击 |
|
||||
| Base64 图片 | 图片以 Base64 返回 | 避免跨域和 URL 泄露 |
|
||||
|
||||
## 前端集成
|
||||
|
||||
### 登录页完整示例
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-form :model="form" :rules="rules" ref="formRef">
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="form.username" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input v-model="form.password" type="password" placeholder="请输入密码" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="验证码" prop="code">
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<el-input v-model="form.code" placeholder="请输入验证码"
|
||||
style="flex: 1;" @keyup.enter="handleLogin" />
|
||||
<img :src="captchaImage" @click="refreshCaptcha"
|
||||
style="cursor: pointer; height: 40px;" title="点击刷新" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleLogin" :loading="loading">
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getCaptcha } from '@/api/common/user';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
|
||||
const formRef = ref();
|
||||
const loading = ref(false);
|
||||
const captchaImage = ref('');
|
||||
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
code: '',
|
||||
key: '',
|
||||
});
|
||||
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
code: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
// 获取验证码
|
||||
const refreshCaptcha = async () => {
|
||||
const res = await getCaptcha();
|
||||
form.value.key = res.key;
|
||||
captchaImage.value = res.captcha;
|
||||
};
|
||||
|
||||
// 登录
|
||||
const handleLogin = async () => {
|
||||
await formRef.value.validate();
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await userStore.login(form.value);
|
||||
ElMessage.success('登录成功');
|
||||
router.push('/');
|
||||
} catch (e: any) {
|
||||
// 登录失败(包括验证码错误),刷新验证码
|
||||
ElMessage.error(e.message || '登录失败');
|
||||
refreshCaptcha();
|
||||
form.value.code = '';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => refreshCaptcha());
|
||||
</script>
|
||||
```
|
||||
|
||||
### 刷新验证码的时机
|
||||
|
||||
| 时机 | 说明 |
|
||||
|------|------|
|
||||
| 页面加载时 | `onMounted` 自动获取 |
|
||||
| 点击图片时 | 用户手动刷新 |
|
||||
| 登录失败后 | 自动刷新并清空输入 |
|
||||
| 验证码过期后 | 用户点击图片重新获取 |
|
||||
|
||||
## 环境配置建议
|
||||
|
||||
### 开发环境
|
||||
|
||||
```ini
|
||||
[CAPTCHA]
|
||||
; 万能验证码,方便调试(登录时输入此码即可跳过验证码)
|
||||
CAPTCHA_BYPASS_CODE = 888888
|
||||
; 验证码长度可以短一些
|
||||
CAPTCHA_LENGTH = 4
|
||||
```
|
||||
|
||||
### 生产环境
|
||||
|
||||
```ini
|
||||
[CAPTCHA]
|
||||
; 不设置万能验证码(留空)
|
||||
CAPTCHA_BYPASS_CODE =
|
||||
; 标准 6 位验证码
|
||||
CAPTCHA_LENGTH = 6
|
||||
; 适当降低防刷限制
|
||||
CAPTCHA_RATE_WINDOW = 60
|
||||
CAPTCHA_RATE_LIMIT = 10
|
||||
; 图片尺寸和干扰强度
|
||||
CAPTCHA_IMAGE_WIDTH = 160
|
||||
CAPTCHA_IMAGE_HEIGHT = 50
|
||||
CAPTCHA_NOISE_LINES = 8
|
||||
CAPTCHA_NOISE_DOTS = 500
|
||||
```
|
||||
@@ -0,0 +1,192 @@
|
||||
# 5.12 系统配置管理
|
||||
|
||||
:::tip 概述
|
||||
系统配置管理用于动态配置参数,支持分组管理。与参数管理的区别:配置管理支持键值对的分组管理(如网站配置、邮件配置),参数管理是独立的参数实体(如单个开关、阈值)。
|
||||
:::
|
||||
|
||||
## 模块特点
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 基类 | BaseLogic(系统共享,不隔离) |
|
||||
| 关联 | 与 ConfigItem 一对多(通过 config_id) |
|
||||
| 用途 | 网站名称、Logo、版权信息等动态配置 |
|
||||
| 缓存 | DictService 两级缓存(请求级 + Redis),减少数据库查询 |
|
||||
|
||||
## 数据库表结构
|
||||
|
||||
### 主表 `think_config`(配置组)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int | 主键,自增 |
|
||||
| name | varchar(150) | 配置组名称(如「网站配置」) |
|
||||
| code | varchar(150) | 配置组编码(如 `website_config`) |
|
||||
| sort | int | 排序(升序) |
|
||||
| note | varchar(255) | 备注说明 |
|
||||
| create_user | varchar(50) | 创建人(BaseModel 自动写入) |
|
||||
| create_time | datetime | 创建时间(BaseModel 自动写入) |
|
||||
| update_user | varchar(50) | 更新人(BaseModel 自动写入) |
|
||||
| update_time | datetime | 更新时间(BaseModel 自动写入) |
|
||||
| is_delete | int | 软删除标记(0=正常,1=已删除) |
|
||||
|
||||
### 子表 `think_config_item`(配置项)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int | 主键,自增 |
|
||||
| config_id | int | 所属配置组 ID(关联 think_config.id) |
|
||||
| name | varchar(150) | 配置项名称(如「网站名称」) |
|
||||
| code | varchar(150) | 配置项编码(如 `site_name`) |
|
||||
| value | varchar(1000) | 配置值 |
|
||||
| options | text | 配置选项(JSON 格式,用于 select/radio 等类型) |
|
||||
| type | varchar(150) | 值类型(text / textarea / number / switch / image / file / select) |
|
||||
| status | int | 状态(1=正常,2=停用) |
|
||||
| sort | int | 排序(升序) |
|
||||
| note | varchar(255) | 配置项备注 |
|
||||
| create_user | varchar(50) | 创建人(BaseModel 自动写入) |
|
||||
| create_time | datetime | 创建时间(BaseModel 自动写入) |
|
||||
| update_user | varchar(50) | 更新人(BaseModel 自动写入) |
|
||||
| update_time | datetime | 更新时间(BaseModel 自动写入) |
|
||||
| is_delete | int | 软删除标记(0=正常,1=已删除) |
|
||||
|
||||
## API 接口
|
||||
|
||||
### 配置组
|
||||
|
||||
| 方法 | 路径 | 权限码 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/config/page` | sys:config:page | 分页列表 |
|
||||
| GET | `/api/config/list` | sys:config:list | 全量列表 |
|
||||
| GET | `/api/config/detail/:id` | sys:config:detail | 详情 |
|
||||
| POST | `/api/config/add` | sys:config:add | 新增 |
|
||||
| PUT | `/api/config/update` | sys:config:update | 修改 |
|
||||
| DELETE | `/api/config/delete/:id` | sys:config:delete | 删除 |
|
||||
|
||||
### 网站配置(快捷接口)
|
||||
|
||||
| 方法 | 路径 | 权限码 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/config/web/index` | sys:config:web | 获取网站配置(按组返回所有配置项) |
|
||||
| POST | `/api/config/web/save` | sys:config:web | 保存网站配置(批量更新配置项值) |
|
||||
|
||||
### 配置项
|
||||
|
||||
| 方法 | 路径 | 权限码 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/config/item/page` | sys:config:item:page | 分页列表 |
|
||||
| GET | `/api/config/item/list` | sys:config:item:list | 全量列表 |
|
||||
| GET | `/api/config/item/detail/:id` | sys:config:item:detail | 详情 |
|
||||
| POST | `/api/config/item/add` | sys:config:item:add | 新增 |
|
||||
| PUT | `/api/config/item/update` | sys:config:item:update | 修改 |
|
||||
| DELETE | `/api/config/item/delete/:id` | sys:config:item:delete | 删除 |
|
||||
|
||||
## 使用场景
|
||||
|
||||
| 场景 | 配置组 | 配置项示例 |
|
||||
|------|--------|----------|
|
||||
| 网站基本信息 | 网站配置 | 网站名称、Logo、版权信息、ICP备案号 |
|
||||
| 邮件配置 | 邮件配置 | SMTP服务器、端口、用户名、密码、发件人 |
|
||||
| 短信配置 | 短信配置 | API Key、模板ID、签名 |
|
||||
| 存储配置 | 存储配置 | 存储类型(本地/OSS)、AccessKey、Bucket |
|
||||
| 支付配置 | 支付配置 | 商户号、密钥、回调地址 |
|
||||
| 其他 | 自定义分组 | 任何需要后台动态配置的参数 |
|
||||
|
||||
## 代码示例
|
||||
|
||||
### 后端获取配置值
|
||||
|
||||
项目中没有 `ConfigService`,配置值通过 `ConfigItem` 模型直接查询:
|
||||
|
||||
```php
|
||||
<?php
|
||||
use app\model\ConfigItem;
|
||||
use app\model\Config;
|
||||
|
||||
// 获取单个配置项的值(按配置组 code + 配置项 code 查询)
|
||||
$value = ConfigItem::whereHas('config', function ($query) {
|
||||
$query->where('code', 'website_config')->where('is_delete', 0);
|
||||
})
|
||||
->where('code', 'site_name')
|
||||
->where('status', 1)
|
||||
->where('is_delete', 0)
|
||||
->value('value');
|
||||
// 返回:'RXThinkCMF'
|
||||
|
||||
// 获取整个配置组的所有配置项(关联数组)
|
||||
$config = Config::where('code', 'website_config')->where('is_delete', 0)->find();
|
||||
if ($config) {
|
||||
$items = ConfigItem::where('config_id', $config->id)
|
||||
->where('status', 1)
|
||||
->where('is_delete', 0)
|
||||
->column('value', 'code');
|
||||
// 返回:['site_name' => 'RXThinkCMF', 'site_logo' => '...', 'copyright' => '...']
|
||||
}
|
||||
|
||||
// 通过 API 获取网站配置(推荐,含类型处理和 URL 拼接)
|
||||
// GET /api/config/web/index → 返回按配置组组织的完整数据
|
||||
```
|
||||
|
||||
### 前端获取配置值
|
||||
|
||||
```typescript
|
||||
// 获取网站配置
|
||||
const { data } = await http.get('/api/config/web/index')
|
||||
|
||||
// 使用配置值
|
||||
const siteName = data.site_name
|
||||
const siteLogo = data.site_logo
|
||||
```
|
||||
|
||||
### 网站配置保存接口
|
||||
|
||||
```bash
|
||||
# -----------------------------------------------------------------------------
|
||||
# 批量保存网站配置(按配置组 code 批量更新)
|
||||
# -----------------------------------------------------------------------------
|
||||
curl -X POST http://localhost:8000/api/config/web/save \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{
|
||||
"website": {
|
||||
"site_name": "RXThinkCMF",
|
||||
"site_logo": "/uploads/logo.png",
|
||||
"copyright": "© 2026 RXThinkCMF"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## 与参数管理的区别
|
||||
|
||||
| 维度 | 配置管理(Config) | 参数管理(Param) |
|
||||
|------|-------------------|-------------------|
|
||||
| 数据结构 | 分组 + 配置项(一对多) | 独立参数实体 |
|
||||
| 适用场景 | 关联性强的配置(如网站配置包含多个字段) | 独立的开关、阈值 |
|
||||
| 管理界面 | 按组展示,表单式编辑 | 列表式管理 |
|
||||
| 获取方式 | `ConfigItem` 模型查询或 `/api/config/web/index` 接口 | `ParamService::getValue(code)` |
|
||||
| 缓存策略 | 两级缓存(请求级 + Redis) | 两级缓存(请求级 + Redis) |
|
||||
|
||||
## 数据库操作示例
|
||||
|
||||
```sql
|
||||
-- 查询所有未删除的配置组
|
||||
SELECT * FROM think_config WHERE is_delete = 0 ORDER BY sort;
|
||||
|
||||
-- 查询某个配置组下的所有启用配置项
|
||||
SELECT ci.* FROM think_config_item ci
|
||||
INNER JOIN think_config c ON ci.config_id = c.id
|
||||
WHERE c.code = 'website_config' AND ci.status = 1 AND ci.is_delete = 0
|
||||
ORDER BY ci.sort;
|
||||
|
||||
-- 直接更新配置项值
|
||||
UPDATE think_config_item SET value = '新网站名称'
|
||||
WHERE code = 'site_name' AND config_id = (
|
||||
SELECT id FROM think_config WHERE code = 'website_config' AND is_delete = 0
|
||||
);
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [5.12 系统参数管理](/zh/core/param.html) — 独立参数实体管理
|
||||
- [5.10 字典模块](/zh/core/dict.html) — 枚举/字典数据管理
|
||||
- [5.11 系统配置管理](/zh/core/config.html) — 本文档
|
||||
@@ -0,0 +1,366 @@
|
||||
# 5.5 跨域资源共享(CORS)
|
||||
|
||||
:::tip 概述
|
||||
CorsMiddleware 处理跨域请求,支持通过 `.env` 动态配置允许的来源域名。配置使用逗号分隔的字符串,中间件将其 `explode` 为数组后做白名单匹配。开发环境可设为 `*` 允许所有来源,生产环境应指定具体域名。
|
||||
:::
|
||||
|
||||
## CORS 工作原理
|
||||
|
||||
### 什么是跨域
|
||||
|
||||
浏览器的同源策略限制了从一个源(协议+域名+端口)去请求另一个源的资源。当源不同时,就产生了跨域。
|
||||
|
||||
```text
|
||||
同源请求(允许):
|
||||
页面:https://admin.example.com/page
|
||||
API: https://admin.example.com/api/user
|
||||
→ 协议+域名+端口相同,同源
|
||||
|
||||
跨域请求(被浏览器阻止):
|
||||
页面:https://admin.example.com/page
|
||||
API: https://api.example.com/user
|
||||
→ 域名不同,跨域
|
||||
|
||||
页面:http://admin.example.com/page
|
||||
API: https://admin.example.com/api/user
|
||||
→ 协议不同,跨域
|
||||
|
||||
页面:https://admin.example.com:80/page
|
||||
API: https://admin.example.com:8080/api/user
|
||||
→ 端口不同,跨域
|
||||
```
|
||||
|
||||
### 简单请求 vs 预检请求
|
||||
|
||||
```text
|
||||
简单请求(Simple Request):
|
||||
满足以下条件的 GET/POST/HEAD 请求:
|
||||
- Content-Type 为 text/plain、multipart/form-data、application/x-www-form-urlencoded
|
||||
- 无自定义请求头
|
||||
→ 浏览器直接发送请求,附带 Origin 头
|
||||
→ 服务端返回 CORS 头,浏览器判断是否允许
|
||||
|
||||
预检请求(Preflight Request):
|
||||
不满足简单请求条件时(如 PUT/DELETE、JSON 请求体、自定义头):
|
||||
→ 浏览器先发送 OPTIONS 请求询问服务端
|
||||
→ 服务端返回允许的方法和头
|
||||
→ 浏览器再发送实际请求
|
||||
```
|
||||
|
||||
### 完整交互流程
|
||||
|
||||
```text
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ 浏览器 │ │ 服务器 │
|
||||
│ (前端) │ │ (后端) │
|
||||
└────┬─────┘ └────┬─────┘
|
||||
│ │
|
||||
│ ─── 1. OPTIONS 预检请求 ──────────────►│
|
||||
│ Origin: https://admin.example.com │
|
||||
│ Access-Control-Request-Method: PUT │
|
||||
│ Access-Control-Request-Headers: │
|
||||
│ Content-Type, Authorization │
|
||||
│ │
|
||||
│ ┌──────────┴──────────┐
|
||||
│ │ CorsMiddleware │
|
||||
│ │ 1. 检查 Origin 白名单│
|
||||
│ │ 2. 返回 CORS 头 │
|
||||
│ │ 3. 返回 204 空响应 │
|
||||
│ └──────────┬──────────┘
|
||||
│ │
|
||||
│ ◄── 2. 预检响应 ─────────────────────│
|
||||
│ 204 No Content │
|
||||
│ Access-Control-Allow-Origin: │
|
||||
│ https://admin.example.com │
|
||||
│ Access-Control-Allow-Methods: │
|
||||
│ GET, POST, PUT, DELETE │
|
||||
│ Access-Control-Allow-Headers: │
|
||||
│ Content-Type, Authorization │
|
||||
│ Access-Control-Max-Age: 3600 │
|
||||
│ │
|
||||
│ ─── 3. 实际请求 ─────────────────────►│
|
||||
│ PUT /api/user/update │
|
||||
│ Origin: https://admin.example.com │
|
||||
│ Authorization: Bearer <token> │
|
||||
│ Content-Type: application/json │
|
||||
│ │
|
||||
│ ┌──────────┴──────────┐
|
||||
│ │ 中间件链 → 控制器 │
|
||||
│ └──────────┬──────────┘
|
||||
│ │
|
||||
│ ◄── 4. 实际响应 ─────────────────────│
|
||||
│ 200 OK │
|
||||
│ Access-Control-Allow-Origin: │
|
||||
│ https://admin.example.com │
|
||||
│ { "code": 0, "data": {...} } │
|
||||
│ │
|
||||
```
|
||||
|
||||
## 配置方式
|
||||
|
||||
### 环境变量
|
||||
|
||||
```ini
|
||||
; .env
|
||||
|
||||
; 开发环境:允许所有来源
|
||||
[CORS]
|
||||
ALLOW_ORIGIN = *
|
||||
|
||||
; 生产环境:指定具体域名(多个用逗号分隔)
|
||||
[CORS]
|
||||
ALLOW_ORIGIN = https://admin.example.com,https://www.example.com
|
||||
|
||||
; 多域名示例
|
||||
[CORS]
|
||||
ALLOW_ORIGIN = https://admin.example.com,https://m.example.com,https://app.example.com
|
||||
```
|
||||
|
||||
### 配置文件
|
||||
|
||||
```php
|
||||
// config/cors.php
|
||||
return [
|
||||
// 允许的来源域名,逗号分隔,* 表示允许所有
|
||||
// 注意:配置含 * 时中间件会回显请求的 Origin,而非直接返回通配符,
|
||||
// 以兼容携带凭证时浏览器不允许 Allow-Origin 为 * 的限制
|
||||
'allow_origin' => explode(',', env('cors.allow_origin', '*')),
|
||||
|
||||
// 允许的请求方法
|
||||
'allow_methods' => 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
|
||||
|
||||
// 允许的请求头
|
||||
'allow_headers' => 'Content-Type, Authorization, X-Requested-With, Accept, Origin, Token',
|
||||
|
||||
// 暴露给前端的响应头,便于前端读取
|
||||
'expose_headers' => 'Authorization',
|
||||
|
||||
// 预检请求(OPTIONS)结果的缓存时间,单位秒
|
||||
'max_age' => 3600,
|
||||
|
||||
// 是否允许携带凭证(Cookie 等)
|
||||
'allow_credentials' => true,
|
||||
];
|
||||
```
|
||||
|
||||
### 配置项说明
|
||||
|
||||
| 配置项 | 说明 | 默认值 | 生产建议 |
|
||||
|--------|------|--------|----------|
|
||||
| `allow_origin` | 允许的来源域名 | `*` | 指定具体域名 |
|
||||
| `allow_methods` | 允许的 HTTP 方法 | `GET, POST, PUT, DELETE, PATCH, OPTIONS` | 按需精简 |
|
||||
| `allow_headers` | 允许的请求头 | 见配置文件 | 按需精简 |
|
||||
| `expose_headers` | 暴露给前端的响应头 | `Authorization` | 按需添加 |
|
||||
| `max_age` | 预检缓存时间(秒) | `3600` | 可适当增大 |
|
||||
| `allow_credentials` | 是否允许携带凭证 | `true` | 前端使用 Cookie 时必须为 true |
|
||||
|
||||
## 中间件代码
|
||||
|
||||
```php
|
||||
// app/middleware/CorsMiddleware.php
|
||||
|
||||
public function handle(Request $request, \Closure $next): Response
|
||||
{
|
||||
$origin = $request->header('Origin', '');
|
||||
$config = config('cors');
|
||||
$allowed = $config['allow_origin'] ?? ['*'];
|
||||
|
||||
// 判断 origin 是否允许:白名单匹配
|
||||
if (in_array('*', $allowed)) {
|
||||
// 配置含 * 时,回显请求 Origin(无 Origin 则用 *)
|
||||
$allowOrigin = $origin ?: '*';
|
||||
} else {
|
||||
// 仅在白名单内才回显 Origin,不在白名单则置空
|
||||
$allowOrigin = in_array($origin, $allowed) ? $origin : '';
|
||||
}
|
||||
|
||||
$header = [
|
||||
'Access-Control-Allow-Origin' => $allowOrigin,
|
||||
'Access-Control-Allow-Credentials' => $config['allow_credentials'] ? 'true' : 'false',
|
||||
'Access-Control-Allow-Methods' => $config['allow_methods'],
|
||||
'Access-Control-Allow-Headers' => $config['allow_headers'],
|
||||
'Access-Control-Expose-Headers' => $config['expose_headers'],
|
||||
'Access-Control-Max-Age' => (string)$config['max_age'],
|
||||
];
|
||||
|
||||
// OPTIONS 预检请求直接返回 204
|
||||
if ($request->isOptions()) {
|
||||
return response('', 204, $header);
|
||||
}
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
// 添加跨域头
|
||||
$response->header($header);
|
||||
|
||||
return $response;
|
||||
}
|
||||
```
|
||||
|
||||
## 关键实现细节
|
||||
|
||||
### Origin 白名单匹配
|
||||
|
||||
配置通过 `explode(',', env(...))` 将逗号分隔的字符串转为数组,中间件使用 `in_array()` 进行精确匹配:
|
||||
|
||||
```text
|
||||
请求 Origin: https://admin.example.com
|
||||
白名单数组: ['https://admin.example.com', 'https://www.example.com']
|
||||
→ in_array() 匹配成功,回显 Origin ✓
|
||||
|
||||
请求 Origin: https://evil.com
|
||||
白名单数组: ['https://admin.example.com', 'https://www.example.com']
|
||||
→ in_array() 匹配失败,$allowOrigin = '' ✗
|
||||
→ 浏览器收到空的 Allow-Origin,阻止前端读取响应
|
||||
```
|
||||
|
||||
### 通配符 * 的特殊处理
|
||||
|
||||
当配置为 `*` 时,`explode(',', '*')` 返回 `['*']`。中间件检测到 `in_array('*', $allowed)` 后,会回显请求的 Origin 而非直接返回 `*`,这是为了兼容浏览器在携带凭证(Cookie)时不允许 `Access-Control-Allow-Origin: *` 的限制。
|
||||
|
||||
```text
|
||||
配置: ALLOW_ORIGIN = *
|
||||
请求 Origin: https://admin.example.com
|
||||
|
||||
中间件行为:
|
||||
in_array('*', ['*']) → true
|
||||
$allowOrigin = $origin(回显 'https://admin.example.com')
|
||||
|
||||
响应头:
|
||||
Access-Control-Allow-Origin: https://admin.example.com ✓
|
||||
Access-Control-Allow-Credentials: true ✓
|
||||
|
||||
如果直接返回 *:
|
||||
Access-Control-Allow-Origin: * ✗
|
||||
Access-Control-Allow-Credentials: true ✗
|
||||
→ 浏览器报错:Allow-Origin 为 * 时不能携带凭证
|
||||
```
|
||||
|
||||
### OPTIONS 预检请求
|
||||
|
||||
浏览器在跨域请求前会先发送 OPTIONS 预检请求,中间件通过 `$request->isOptions()` 判断后,直接返回 `response('', 204, $header)` 空响应,避免进入业务逻辑。
|
||||
|
||||
```text
|
||||
OPTIONS /api/user/update HTTP/1.1
|
||||
Origin: https://admin.example.com
|
||||
Access-Control-Request-Method: PUT
|
||||
Access-Control-Request-Headers: Content-Type, Authorization
|
||||
|
||||
→ 中间件拦截,返回 204 + CORS 头
|
||||
→ 不进入 AuthMiddleware / Controller
|
||||
```
|
||||
|
||||
## 与其他中间件的协作
|
||||
|
||||
```text
|
||||
请求进入
|
||||
│
|
||||
▼
|
||||
CorsMiddleware(全局,第一个执行)
|
||||
│ OPTIONS → 直接返回 204 + CORS 头
|
||||
│ 普通请求 → 设置 CORS 头,继续
|
||||
▼
|
||||
AuthMiddleware
|
||||
│ 校验 JWT + 权限
|
||||
▼
|
||||
TenantMiddleware
|
||||
│ 注入租户上下文
|
||||
▼
|
||||
Controller
|
||||
```
|
||||
|
||||
:::tip 执行顺序
|
||||
CorsMiddleware 必须是第一个执行的中间件,确保 OPTIONS 预检请求在认证之前就被处理,否则预检请求会因缺少 Token 而被 AuthMiddleware 拒绝。
|
||||
:::
|
||||
|
||||
## 常见问题排查
|
||||
|
||||
### 问题 1:No 'Access-Control-Allow-Origin' header
|
||||
|
||||
**原因:** 请求的 Origin 不在白名单中。
|
||||
|
||||
**解决:**
|
||||
```ini
|
||||
# 检查 .env 中的 ALLOW_ORIGIN 是否包含前端的域名
|
||||
[CORS]
|
||||
ALLOW_ORIGIN = https://admin.example.com
|
||||
```
|
||||
|
||||
### 问题 2:Allow-Origin 为 * 时携带凭证失败
|
||||
|
||||
**原因:** 浏览器规定 `Access-Control-Allow-Origin: *` 时不能携带 Cookie。
|
||||
|
||||
**解决:** 中间件已自动处理——配置为 `*` 时回显请求 Origin 而非返回通配符。如果仍报错,检查前端是否设置了 `withCredentials: true`。
|
||||
|
||||
### 问题 3:预检请求返回 401/403
|
||||
|
||||
**原因:** OPTIONS 请求被 AuthMiddleware 拦截。
|
||||
|
||||
**解决:** 确保 CorsMiddleware 在 AuthMiddleware 之前执行。检查中间件注册顺序:
|
||||
|
||||
```php
|
||||
// app/middleware.php(全局中间件)
|
||||
return [
|
||||
\app\middleware\CorsMiddleware::class, // 必须在最前面
|
||||
];
|
||||
```
|
||||
|
||||
### 问题 4:自定义请求头被拒绝
|
||||
|
||||
**原因:** 请求头不在 `allow_headers` 列表中。
|
||||
|
||||
**解决:**
|
||||
```php
|
||||
// config/cors.php
|
||||
'allow_headers' => 'Content-Type, Authorization, X-Requested-With, Accept, Origin, Token, X-Custom-Header',
|
||||
```
|
||||
|
||||
### 问题 5:前端无法读取自定义响应头
|
||||
|
||||
**原因:** 响应头不在 `expose_headers` 列表中。
|
||||
|
||||
**解决:**
|
||||
```php
|
||||
// config/cors.php
|
||||
'expose_headers' => 'Authorization, X-Total-Count, X-Page-Count',
|
||||
```
|
||||
|
||||
## 环境配置建议
|
||||
|
||||
### 开发环境
|
||||
|
||||
```ini
|
||||
[CORS]
|
||||
ALLOW_ORIGIN = *
|
||||
```
|
||||
|
||||
允许所有来源,方便前后端分离开发(前端 `localhost:3000`,后端 `localhost:8080`)。
|
||||
|
||||
### 测试环境
|
||||
|
||||
```ini
|
||||
[CORS]
|
||||
ALLOW_ORIGIN = https://test.admin.example.com
|
||||
```
|
||||
|
||||
指定测试域名,提前验证跨域配置。
|
||||
|
||||
### 生产环境
|
||||
|
||||
```ini
|
||||
[CORS]
|
||||
ALLOW_ORIGIN = https://admin.example.com,https://www.example.com
|
||||
```
|
||||
|
||||
只允许正式域名,不使用 `*`。
|
||||
|
||||
## CORS 响应头速查
|
||||
|
||||
| 响应头 | 说明 | 示例值 |
|
||||
|--------|------|--------|
|
||||
| `Access-Control-Allow-Origin` | 允许的来源 | `https://admin.example.com` |
|
||||
| `Access-Control-Allow-Methods` | 允许的方法 | `GET, POST, PUT, DELETE` |
|
||||
| `Access-Control-Allow-Headers` | 允许的请求头 | `Content-Type, Authorization` |
|
||||
| `Access-Control-Expose-Headers` | 暴露的响应头 | `Authorization` |
|
||||
| `Access-Control-Max-Age` | 预检缓存时间 | `3600` |
|
||||
| `Access-Control-Allow-Credentials` | 是否允许凭证 | `true` |
|
||||
@@ -0,0 +1,132 @@
|
||||
# 5.9 数据权限
|
||||
|
||||
:::tip 概述
|
||||
数据权限控制用户能看到哪些数据,基于角色的 `data_scope` 字段进行过滤。支持三种范围:全部数据、本部门数据、仅本人数据。
|
||||
:::
|
||||
|
||||
## 三种范围
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ data_scope = 1 │
|
||||
│ 全部数据(不过滤) │
|
||||
│ │
|
||||
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
|
||||
│ │ A │ │ B │ │ C │ │ D │ │ E │ ← 所有部门所有人的数据 │
|
||||
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ data_scope = 2 │
|
||||
│ 本部门数据 │
|
||||
│ │
|
||||
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
|
||||
│ │ A │ │ B │ │███│ │ D │ │ E │ ← 只看到本部门数据 │
|
||||
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ data_scope = 3 │
|
||||
│ 仅本人数据 │
|
||||
│ │
|
||||
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
|
||||
│ │ A │ │ B │ │███│ │ D │ │ E │ ← 只看到自己创建的数据 │
|
||||
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 配置方式
|
||||
|
||||
在 Logic 中配置数据权限字段即可启用:
|
||||
|
||||
```php
|
||||
class UserLogic extends BaseTenantLogic
|
||||
{
|
||||
/**
|
||||
* 数据权限:部门关联字段名(用于按部门过滤)
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $dataScopeDeptField = 'dept_id';
|
||||
}
|
||||
|
||||
class ArticleLogic extends BaseTenantLogic
|
||||
{
|
||||
/**
|
||||
* 数据权限:归属字段名(用于按创建人过滤)
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $dataScopeUserField = 'create_user';
|
||||
}
|
||||
```
|
||||
|
||||
## 实现机制
|
||||
|
||||
```php
|
||||
// app/BaseLogic.php
|
||||
|
||||
protected function applyDataScope($model)
|
||||
{
|
||||
// 未配置数据权限字段则跳过
|
||||
if (empty($this->dataScopeUserField) && empty($this->dataScopeDeptField)) {
|
||||
return $model;
|
||||
}
|
||||
|
||||
// 获取当前用户信息
|
||||
$userInfo = request()->userInfo ?? null;
|
||||
$uid = $userInfo->uid ?? 0;
|
||||
$roleId = $userInfo->role_id ?? 0;
|
||||
|
||||
// 获取角色的数据权限范围
|
||||
$dataScope = $this->getRoleDataScope($roleId);
|
||||
|
||||
// 1=全部数据,不做过滤
|
||||
if ($dataScope == 1) {
|
||||
return $model;
|
||||
}
|
||||
|
||||
// 2=本部门数据
|
||||
if ($dataScope == 2 && $this->dataScopeDeptField) {
|
||||
$deptId = $this->getUserDeptId($uid);
|
||||
if ($deptId) {
|
||||
$model = $model->where($this->dataScopeDeptField, $deptId);
|
||||
}
|
||||
return $model;
|
||||
}
|
||||
|
||||
// 3=仅本人数据
|
||||
if ($dataScope == 3 && $this->dataScopeUserField) {
|
||||
$model = $model->where($this->dataScopeUserField, $userInfo->username);
|
||||
return $model;
|
||||
}
|
||||
|
||||
return $model;
|
||||
}
|
||||
```
|
||||
|
||||
## 角色配置
|
||||
|
||||
在角色管理中设置 `data_scope` 字段:
|
||||
|
||||
| 值 | 范围 | 适用场景 |
|
||||
|----|------|----------|
|
||||
| 1 | 全部数据 | 管理层、超级管理员 |
|
||||
| 2 | 本部门数据 | 部门经理 |
|
||||
| 3 | 仅本人数据 | 普通员工 |
|
||||
|
||||
## 缓存说明
|
||||
|
||||
角色的数据权限范围带 1 小时缓存(`role_data_scope_{roleId}`)。修改角色权限后需等待缓存过期或手动清除。
|
||||
|
||||
## 超级管理员
|
||||
|
||||
`uid=1` 的超级管理员不受数据权限限制,始终看到全部数据。详见 [RBAC 权限管理](/zh/core/rbac.html)。
|
||||
|
||||
## 与租户隔离的关系
|
||||
|
||||
数据权限和租户隔离是两个独立的维度:
|
||||
- **租户隔离**:按 `tenant_id` 过滤,不同租户数据完全隔离
|
||||
- **数据权限**:在同一租户内,按部门或创建人进一步过滤
|
||||
|
||||
两者可以同时生效,租户隔离优先级更高。
|
||||
@@ -0,0 +1,303 @@
|
||||
# 5.7 演示模式
|
||||
|
||||
:::tip 概述
|
||||
演示模式是系统内置的安全机制,用于公共演示环境的数据保护。开启后,DemoMiddleware 自动拦截所有写操作(POST/PUT/DELETE/PATCH),仅允许读操作(GET)通过,防止演示环境被随意修改。通过 `#[DemoAllow]` 注解可豁免特定接口。
|
||||
:::
|
||||
|
||||
## 应用场景
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 演示模式典型场景 │
|
||||
│ │
|
||||
│ ✅ 在线体验站:开放给潜在客户试用,但不允许修改数据 │
|
||||
│ ✅ 培训环境:学员可以浏览所有功能,但不能增删改 │
|
||||
│ ✅ 公开演示:展会/发布会现场演示,防止误操作 │
|
||||
│ ✅ 安全评审:安全团队审查时只允许只读访问 │
|
||||
│ │
|
||||
│ ⚠️ 部分"安全的写操作"仍需放行(如刷新缓存、代码预览) │
|
||||
│ → 通过 #[DemoAllow] 注解豁免 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
```ini
|
||||
# .env
|
||||
[APP]
|
||||
# 开启演示模式(true=开启,false=关闭)
|
||||
DEMO = true
|
||||
```
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `DEMO` | `false` | 演示模式开关,`true` 开启拦截,`false` 正常运行 |
|
||||
|
||||
:::warning 生产环境务必关闭
|
||||
演示模式会拦截所有写操作,生产环境必须设为 `false`,否则用户无法执行任何增删改操作。
|
||||
:::
|
||||
|
||||
## 拦截规则
|
||||
|
||||
```text
|
||||
请求进入
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ ① DEMO = false ? │──── 是 ──→ 直接放行(非演示环境)
|
||||
└──────────┬───────────────┘
|
||||
│ 否
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ ② 请求方法 = GET ? │──── 是 ──→ 直接放行(读操作)
|
||||
└──────────┬───────────────┘
|
||||
│ 否(POST/PUT/DELETE/PATCH)
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ ③ 标注 #[DemoAllow] ? │──── 是 ──→ 直接放行(白名单豁免)
|
||||
└──────────┬───────────────┘
|
||||
│ 否
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ 返回 403 │
|
||||
│ "演示环境,禁止操作" │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
| 请求方法 | 行为 | 说明 |
|
||||
|---------|------|------|
|
||||
| GET | ✅ 放行 | 读操作始终允许 |
|
||||
| POST | ❌ 拦截 | 新增操作 |
|
||||
| PUT | ❌ 拦截 | 修改操作 |
|
||||
| DELETE | ❌ 拦截 | 删除操作 |
|
||||
| PATCH | ❌ 拦截 | 部分修改操作 |
|
||||
| 任意方法 + `#[DemoAllow]` | ✅ 放行 | 白名单豁免 |
|
||||
|
||||
## 中间件执行位置
|
||||
|
||||
DemoMiddleware 位于需认证路由组的中间件链中,执行顺序:
|
||||
|
||||
```text
|
||||
CorsMiddleware(全局)
|
||||
→ AuthMiddleware(认证 + 权限)
|
||||
→ TenantMiddleware(租户上下文)
|
||||
→ DemoMiddleware(演示模式拦截) ← 本中间件
|
||||
→ LogMiddleware(操作日志)
|
||||
→ 控制器
|
||||
```
|
||||
|
||||
:::tip 执行位置说明
|
||||
DemoMiddleware 在 AuthMiddleware 和 TenantMiddleware 之后执行,意味着:
|
||||
- 未登录用户已被 AuthMiddleware 拦截(401),不会到达演示模式判断
|
||||
- 租户状态已由 TenantMiddleware 校验,确保用户合法
|
||||
- 演示模式仅拦截"合法用户的写操作",不影响认证流程
|
||||
:::
|
||||
|
||||
## 核心代码
|
||||
|
||||
### DemoMiddleware
|
||||
|
||||
```php
|
||||
// app/middleware/DemoMiddleware.php
|
||||
|
||||
class DemoMiddleware
|
||||
{
|
||||
public function handle(Request $request, \Closure $next): Response
|
||||
{
|
||||
// ① 非演示环境,直接放行
|
||||
if (!env('app_demo', false)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// ② GET 请求放行(读操作)
|
||||
$method = strtoupper($request->method());
|
||||
if ($method === 'GET') {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// ③ 检查是否有 #[DemoAllow] 注解
|
||||
$controllerClass = $request->controller();
|
||||
$action = $request->action();
|
||||
|
||||
// 补全命名空间(ThinkPHP 控制器解析不带命名空间前缀)
|
||||
if (strpos($controllerClass, '\\') === false) {
|
||||
$controllerClass = 'app\\controller\\' . $controllerClass;
|
||||
}
|
||||
|
||||
if (AttributeService::hasDemoAllow($controllerClass, $action)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// ④ 演示环境,拦截写操作
|
||||
return json([
|
||||
'code' => 403,
|
||||
'msg' => '演示环境,禁止操作',
|
||||
'data' => null,
|
||||
], 403);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### DemoAllow 注解
|
||||
|
||||
```php
|
||||
// app/attribute/DemoAllow.php
|
||||
|
||||
#[Attribute(Attribute::TARGET_METHOD)] // 仅支持方法级,不支持类级
|
||||
class DemoAllow
|
||||
{
|
||||
// 无参数的标记注解,仅用于标识"允许在演示环境执行"
|
||||
}
|
||||
```
|
||||
|
||||
### 注解读取(AttributeService)
|
||||
|
||||
```php
|
||||
// app/service/AttributeService.php
|
||||
|
||||
// 通过反射判断方法是否标注了 #[DemoAllow]
|
||||
public static function hasDemoAllow(string $controllerClass, string $method): bool
|
||||
{
|
||||
$attributes = self::getMethodAttributes($controllerClass, $method);
|
||||
return $attributes['demoAllow'] !== null;
|
||||
}
|
||||
```
|
||||
|
||||
:::details 注解解析流程
|
||||
1. 以 `类名@方法名` 为键查缓存,命中直接返回
|
||||
2. 通过 `ReflectionMethod` 获取方法上的所有 PHP 8 Attribute
|
||||
3. 遍历属性实例,匹配 `DemoAllow` 类型并存入结果
|
||||
4. 写入缓存,同一请求内不重复反射
|
||||
:::
|
||||
|
||||
## 白名单示例
|
||||
|
||||
系统中已标注 `#[DemoAllow]` 的方法:
|
||||
|
||||
| 控制器 | 方法 | 说明 | 为什么放行 |
|
||||
|--------|------|------|-----------|
|
||||
| `DictController` | `refreshCache` | 刷新字典缓存 | 仅清除缓存,不修改业务数据 |
|
||||
| `GeneratorController` | `preview` | 代码生成预览 | 仅预览不写盘,展示生成器能力 |
|
||||
|
||||
### 使用示例
|
||||
|
||||
```php
|
||||
use app\attribute\DemoAllow;
|
||||
use app\attribute\Permission;
|
||||
use app\attribute\Log;
|
||||
|
||||
class DictController extends BaseController
|
||||
{
|
||||
/**
|
||||
* 刷新字典缓存
|
||||
*
|
||||
* 演示环境下也可执行:清除缓存不影响业务数据,
|
||||
* 且是展示字典功能的必要操作。
|
||||
*/
|
||||
#[Log('字典管理-刷新缓存', Log::TYPE_CLEAR)]
|
||||
#[Permission('sys:dict:update', '修改字典')]
|
||||
#[DemoAllow] // ← 演示模式白名单
|
||||
public function refreshCache(): Json
|
||||
{
|
||||
DictService::clearAllCache();
|
||||
return $this->success(null, '缓存刷新成功');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
:::warning 注意事项
|
||||
- `#[DemoAllow]` 仅支持方法级标注,不支持类级(与 `#[Permission]` 不同)
|
||||
- 仅对"安全的写操作"使用,如刷新缓存、预览、导出等
|
||||
- 不要对增删改数据的接口使用,否则失去演示模式的保护意义
|
||||
:::
|
||||
|
||||
## 前端适配
|
||||
|
||||
前端在收到 403 响应时,需识别演示模式拦截并给出友好提示:
|
||||
|
||||
```typescript
|
||||
// HTTP 响应拦截器
|
||||
axios.interceptors.response.use(response => {
|
||||
const { code, msg } = response.data;
|
||||
|
||||
// 演示模式拦截
|
||||
if (code === 403 && msg === '演示环境,禁止操作') {
|
||||
ElMessage.warning('当前为演示环境,不允许执行此操作');
|
||||
return Promise.reject(response.data);
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
```
|
||||
|
||||
前端可根据需要,在演示模式下隐藏或禁用写操作按钮:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- 演示模式下禁用删除按钮 -->
|
||||
<el-button
|
||||
type="danger"
|
||||
:disabled="isDemoMode"
|
||||
@click="handleDelete"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAppStore } from '@/store/modules/projectSetting';
|
||||
|
||||
const isDemoMode = computed(() => import.meta.env.VITE_DEMO_MODE === 'true');
|
||||
</script>
|
||||
```
|
||||
|
||||
## 响应示例
|
||||
|
||||
当演示模式拦截写操作时,返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 403,
|
||||
"msg": "演示环境,禁止操作",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
HTTP 状态码为 `403`。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 演示模式下登录还能用吗?
|
||||
|
||||
能。登录接口 `/api/login` 使用 POST 方法,但它位于**公开接口组**(不经过 DemoMiddleware),因此不受演示模式影响。
|
||||
|
||||
### Q: 如何让某个 POST 接口在演示模式下也能用?
|
||||
|
||||
在控制器方法上标注 `#[DemoAllow]` 注解即可:
|
||||
|
||||
```php
|
||||
#[DemoAllow]
|
||||
public function mySafeAction(): Json
|
||||
{
|
||||
// 演示模式下也可执行
|
||||
}
|
||||
```
|
||||
|
||||
### Q: 演示模式和权限控制会冲突吗?
|
||||
|
||||
不会。两者是独立机制,按顺序执行:
|
||||
1. **AuthMiddleware** 先校验权限(无权限返回 403 "无访问权限")
|
||||
2. **DemoMiddleware** 再判断演示模式(拦截写操作返回 403 "演示环境,禁止操作")
|
||||
|
||||
两者的 403 含义不同,前端可通过 `msg` 字段区分。
|
||||
|
||||
### Q: 临时关闭演示模式需要重启服务吗?
|
||||
|
||||
取决于 `.env` 的缓存配置:
|
||||
- **文件缓存**:修改 `.env` 后需清缓存或重启(`php think clear`)
|
||||
- **Redis 缓存**:同上
|
||||
- **开发环境**(`APP_DEBUG=true`):每次请求重新读取 `.env`,无需重启
|
||||
|
||||
### Q: `#[DemoAllow]` 支持类级标注吗?
|
||||
|
||||
不支持。`DemoAllow` 注解的 `Attribute` 目标仅为 `TARGET_METHOD`(方法级)。如果需要整个控制器放行,需在每个方法上单独标注。
|
||||
@@ -0,0 +1,333 @@
|
||||
# 5.11 字典配置与使用
|
||||
|
||||
:::tip 本章概要
|
||||
数据字典的完整使用流程,从新增字典类型到在代码中使用字典数据的实操指南。字典是系统中管理枚举值的标准方式,替代硬编码,实现"数据与代码分离"。
|
||||
:::
|
||||
|
||||
## 数据字典结构
|
||||
|
||||
数据字典由两层组成:
|
||||
|
||||
```text
|
||||
字典类型(think_dict) 字典项(think_dict_item)
|
||||
├── gender(性别) ├── 1 → 男
|
||||
│ └── 2 → 女
|
||||
├── user_status(用户状态) ├── 0 → 禁用
|
||||
│ └── 1 → 启用
|
||||
├── article_status(文章状态) ├── 0 → 草稿
|
||||
│ └── 1 → 已发布
|
||||
└── ...
|
||||
```
|
||||
|
||||
| 层级 | 表 | 说明 |
|
||||
|------|-----|------|
|
||||
| 字典类型 | `think_dict` | 字典分类(如"性别""用户状态") |
|
||||
| 字典项 | `think_dict_item` | 字典的具体选项(如"1-男""2-女") |
|
||||
|
||||
### 表结构
|
||||
|
||||
**think_dict(字典主表):**
|
||||
|
||||
| 字段 | 类型 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `id` | INT | 主键 | 1 |
|
||||
| `name` | VARCHAR(50) | 字典名称 | 性别 |
|
||||
| `code` | VARCHAR(50) | 字典编码(唯一) | gender |
|
||||
| `remark` | VARCHAR(500) | 备注 | 性别字典 |
|
||||
| `is_delete` | TINYINT(1) | 软删除 | 0 |
|
||||
| `create_user` | VARCHAR(50) | 创建人 | admin |
|
||||
| `create_time` | DATETIME | 创建时间 | |
|
||||
| `update_user` | VARCHAR(50) | 更新人 | |
|
||||
| `update_time` | DATETIME | 更新时间 | |
|
||||
|
||||
**think_dict_item(字典项表):**
|
||||
|
||||
| 字段 | 类型 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `id` | INT | 主键 | 1 |
|
||||
| `dict_id` | INT | 字典 ID(外键) | 1 |
|
||||
| `name` | VARCHAR(50) | 项名称(显示标签) | 男 |
|
||||
| `value` | VARCHAR(50) | 项值(存储值) | 1 |
|
||||
| `sort` | INT | 排序 | 1 |
|
||||
| `note` | VARCHAR(200) | 备注 | |
|
||||
| `is_delete` | TINYINT(1) | 软删除 | 0 |
|
||||
| `create_user` | VARCHAR(50) | 创建人 | |
|
||||
| `create_time` | DATETIME | 创建时间 | |
|
||||
| `update_user` | VARCHAR(50) | 更新人 | |
|
||||
| `update_time` | DATETIME | 更新时间 | |
|
||||
|
||||
## 系统内置字典
|
||||
|
||||
| 字典编码 | 字典名称 | 字典项 |
|
||||
|----------|---------|--------|
|
||||
| `gender` | 性别 | 0=女, 1=男 |
|
||||
| `user_status` | 用户状态 | 0=禁用, 1=启用 |
|
||||
| `article_status` | 文章状态 | 0=草稿, 1=已发布 |
|
||||
| `tenant_status` | 租户状态 | 0=禁用, 1=启用 |
|
||||
| `example_type` | 案例类型 | 0=类型一, 1=类型二 |
|
||||
| `example_status` | 案例状态 | 0=禁用, 1=启用 |
|
||||
| `data_scope` | 数据权限 | 1=全部, 2=本部门, 3=仅本人 |
|
||||
|
||||
## 实操:新增一个字典类型
|
||||
|
||||
### 场景
|
||||
|
||||
需要为"案例类型"模块添加一个下拉选项:`1-基础 2-进阶 3-高级`。
|
||||
|
||||
### 第 1 步:在后台添加字典类型
|
||||
|
||||
登录管理后台,进入「数据管理 → 字典管理」:
|
||||
|
||||
1. 点击「新增」按钮
|
||||
2. 填写信息:
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| 字典名称 | 案例类型 |
|
||||
| 字典编码 | example_type |
|
||||
| 状态 | 启用 |
|
||||
|
||||
3. 保存
|
||||
|
||||
### 第 2 步:添加字典项
|
||||
|
||||
在字典类型列表中点击「案例类型」,进入字典项管理:
|
||||
|
||||
1. 添加字典项:
|
||||
|
||||
| 字典值 | 字典标签 | 排序 |
|
||||
|--------|---------|------|
|
||||
| 1 | 基础 | 1 |
|
||||
| 2 | 进阶 | 2 |
|
||||
| 3 | 高级 | 3 |
|
||||
|
||||
2. 保存
|
||||
|
||||
### 第 3 步:在 Logic 中关联字典
|
||||
|
||||
在模块的 Logic 中配置 `serializeMaps`:
|
||||
|
||||
```php
|
||||
class ExampleLogic extends BaseLogic
|
||||
{
|
||||
// ... 其他配置 ...
|
||||
|
||||
/**
|
||||
* 枚举显示名映射
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $serializeMaps = [
|
||||
'type' => 'example_type',
|
||||
'status' => 'example_status',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
配置后,列表和详情接口会自动将 `type=1` 转换为 `typeText='基础'`。
|
||||
|
||||
### 第 4 步:在 Validate 中校验枚举值(可选)
|
||||
|
||||
```php
|
||||
class ExampleValidate extends Validate
|
||||
{
|
||||
protected $rule = [
|
||||
'name' => 'require',
|
||||
'type' => 'in:1,2,3', // 限制为合法枚举值
|
||||
'status' => 'in:0,1',
|
||||
];
|
||||
protected $scene = [
|
||||
'add' => ['name', 'type', 'status'],
|
||||
'update' => ['name', 'type', 'status'],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
## 在后端代码中使用
|
||||
|
||||
### 方式 1:通过 serializeMaps 自动转换(推荐)
|
||||
|
||||
```php
|
||||
// Logic 中配置后,查询数据自动补全
|
||||
protected array $serializeMaps = [
|
||||
'status' => 'example_status',
|
||||
];
|
||||
|
||||
// 查询结果自动包含 statusText 字段
|
||||
// { "id": 1, "name": "测试", "status": 1, "statusText": "启用" }
|
||||
```
|
||||
|
||||
### 方式 2:手动调用 DictService
|
||||
|
||||
```php
|
||||
use app\service\DictService;
|
||||
|
||||
// 根据值获取名称
|
||||
$text = DictService::getText('example_status', 1); // '启用'
|
||||
|
||||
// 根据名称反向获取值(导入时用)
|
||||
$value = DictService::getValue('example_status', '启用'); // '1'
|
||||
|
||||
// 获取下拉框选项
|
||||
$options = DictService::getOptions('example_status');
|
||||
// [['label'=>'启用','value'=>'1'], ['label'=>'禁用','value'=>'0']]
|
||||
|
||||
// 获取完整字典项(含 id、sort、note)
|
||||
$items = DictService::getFullItems('example_status');
|
||||
```
|
||||
|
||||
### 方式 3:在导入时反向映射
|
||||
|
||||
```php
|
||||
// ExampleLogic::import()
|
||||
foreach ($data as &$row) {
|
||||
// Excel 中"基础" → 数据库中 1
|
||||
if (!empty($row['type']) && !is_numeric($row['type'])) {
|
||||
$row['type'] = DictService::getValue('example_type', $row['type']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 在前端使用
|
||||
|
||||
### 获取字典项
|
||||
|
||||
```typescript
|
||||
// ui/src/api/common/index.ts
|
||||
export function getDictItemList(code: string) {
|
||||
return http.request({
|
||||
url: '/dict/item/getDictItemList/' + code,
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 下拉框使用
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-select v-model="formData.status" placeholder="请选择状态">
|
||||
<el-option
|
||||
v-for="item in statusOptions"
|
||||
:key="item.value"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { getDictItemList } from '@/api/common/index';
|
||||
|
||||
const statusOptions = ref([]);
|
||||
|
||||
onMounted(async () => {
|
||||
statusOptions.value = await getDictItemList('example_status');
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### 多个字典批量获取
|
||||
|
||||
```typescript
|
||||
// 同时获取多个字典选项
|
||||
const [genderOptions, statusOptions] = await Promise.all([
|
||||
getDictItemList('gender'),
|
||||
getDictItemList('user_status'),
|
||||
]);
|
||||
```
|
||||
|
||||
### 封装为通用 Hook
|
||||
|
||||
```typescript
|
||||
// ui/src/hooks/web/useDict.ts
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { getDictItemList } from '@/api/common/index';
|
||||
|
||||
export function useDict(code: string) {
|
||||
const options = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
options.value = await getDictItemList(code);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => load());
|
||||
|
||||
return { options, loading, reload: load };
|
||||
}
|
||||
|
||||
// 使用
|
||||
const { options: genderOptions } = useDict('gender');
|
||||
const { options: statusOptions } = useDict('user_status');
|
||||
```
|
||||
|
||||
### 字典值显示为 Tag
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
|
||||
{{ row.statusText }}
|
||||
</el-tag>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 缓存管理
|
||||
|
||||
DictService 采用两级缓存(请求级 + 持久缓存,1小时过期)。修改字典数据后需刷新缓存:
|
||||
|
||||
```bash
|
||||
# 调用刷新接口
|
||||
GET /api/dict/refreshCache
|
||||
```
|
||||
|
||||
### 缓存失效时机
|
||||
|
||||
| 时机 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| 字典项新增/修改/删除 | 自动清除该字典缓存 | DictLogic 中调用 `DictService::clearCache($code)` |
|
||||
| 手动刷新 | `GET /dict/refreshCache` | 清除所有字典缓存 |
|
||||
| 缓存过期 | 自动失效 | 1 小时 TTL |
|
||||
|
||||
:::warning 字典数据变更后必须刷新缓存
|
||||
修改字典项后,如果不刷新缓存,前端显示的可能仍是旧数据。建议在字典管理的增删改接口中自动调用 `clearCache()`。
|
||||
:::
|
||||
|
||||
## 字典 vs 硬编码
|
||||
|
||||
| 方式 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| **字典(推荐)** | 可在后台动态修改,无需改代码重启 | 需要额外的数据库查询(有缓存) |
|
||||
| 硬编码 | 简单直接,无额外查询 | 修改需改代码重新部署 |
|
||||
|
||||
**何时用字典:** 值可能变化(状态、类型、分类)、需要前端下拉框、需要多处复用
|
||||
|
||||
**何时硬编码:** 值几乎不变(性别只有男女)、单次使用、性能极致敏感
|
||||
|
||||
## 命名规范
|
||||
|
||||
| 类型 | 命名规范 | 示例 |
|
||||
|------|---------|------|
|
||||
| 字典编码 | 小写下划线 | `example_type`、`user_status` |
|
||||
| 字典值 | 数字字符串 | `1`、`2`、`3` |
|
||||
| 字典标签 | 中文名称 | `基础`、`启用` |
|
||||
| 字段名 | 小写下划线 | `type`、`status` |
|
||||
| 显示名字段 | 字段名 + Text | `typeText`、`statusText` |
|
||||
|
||||
## 最佳实践
|
||||
|
||||
| 实践 | 说明 |
|
||||
|------|------|
|
||||
| 编码统一 | 同一字典编码全局唯一,如 `user_status` |
|
||||
| 值用数字 | 字典值使用数字字符串(`1`、`2`),便于排序和比较 |
|
||||
| 标签用中文 | 字典标签使用中文(`启用`、`禁用`),便于前端直接显示 |
|
||||
| 排序有序 | 通过 `sort` 字段控制下拉框选项顺序 |
|
||||
| 备注说明 | 在 `note` 字段记录特殊值的含义,便于维护 |
|
||||
| Validate 校验 | 在 Validate 中用 `in:0,1` 限制合法值 |
|
||||
| 导入导出 | 导入时用 `getValue` 反向映射,导出时用 `getText` 正向翻译 |
|
||||
@@ -0,0 +1,326 @@
|
||||
# 5.10 字典模块
|
||||
|
||||
:::tip 概述
|
||||
DictService 提供字典项的读取、正反向转换与缓存管理,是字典体系的核心服务。采用两级缓存(请求级 + 持久缓存),同一请求内不重复查库,跨请求复用持久缓存。
|
||||
:::
|
||||
|
||||
## 字典体系架构
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 字典体系 │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ think_dict │ │ think_dict_item │ │
|
||||
│ │ 字典主表 │ │ 字典项表 │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ id │◄───│ dict_id │ │
|
||||
│ │ name (字典名称) │ │ name (项名称) │ │
|
||||
│ │ code (字典编码) │ │ value (项值) │ │
|
||||
│ │ remark │ │ sort (排序) │ │
|
||||
│ └──────────────────┘ │ note (备注) │ │
|
||||
│ └──────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ DictService │ │ Logic 层 │ │
|
||||
│ │ 字典服务 │ │ serializeMaps │ │
|
||||
│ │ │ │ 自动翻译 │ │
|
||||
│ │ getText() │ │ │ │
|
||||
│ │ getValue() │◄───│ 'status' => │ │
|
||||
│ │ getOptions() │ │ 'user_status' │ │
|
||||
│ │ clearCache() │ │ │ │
|
||||
│ └──────────────────┘ └──────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 缓存架构
|
||||
|
||||
```text
|
||||
请求级缓存(static::$localCache) ← 同一请求内不重复查库
|
||||
│ 未命中
|
||||
▼
|
||||
持久缓存(cache() 门面,1小时) ← 跨请求复用(file/redis)
|
||||
│ 未命中
|
||||
▼
|
||||
数据库查询(dict → dict_item) ← 查询后写入两级缓存
|
||||
```
|
||||
|
||||
### 缓存 Key 格式
|
||||
|
||||
| 缓存类型 | Key 格式 | 存储内容 |
|
||||
|----------|---------|---------|
|
||||
| 请求级 | `$localCache['gender']` | `['1'=>'男', '2'=>'女']` |
|
||||
| 请求级(完整) | `$localCache['full_gender']` | `[{id, name, value, sort, note}, ...]` |
|
||||
| 持久缓存 | `dict_gender` | `['1'=>'男', '2'=>'女']` |
|
||||
| 持久缓存(完整) | `dict_full_gender` | `[{id, name, value, sort, note}, ...]` |
|
||||
|
||||
### 缓存生命周期
|
||||
|
||||
```text
|
||||
1. 首次查询 → 数据库 → 写入持久缓存 + 请求级缓存
|
||||
2. 同一请求再次命中 → 直接返回请求级缓存(最快)
|
||||
3. 不同请求命中 → 读取持久缓存(不查库)
|
||||
4. 缓存过期(1小时)→ 重新查库 → 更新两级缓存
|
||||
5. 字典数据变更 → 调用 clearCache() 清除 → 下次查询重新加载
|
||||
```
|
||||
|
||||
## 核心方法
|
||||
|
||||
### getText — 根据值获取名称(最常用)
|
||||
|
||||
```php
|
||||
DictService::getText('gender', 1); // '男'
|
||||
DictService::getText('user_status', 1); // '启用'
|
||||
DictService::getText('gender', ''); // ''(空值返回空字符串)
|
||||
DictService::getText('gender', null); // ''(空值返回空字符串)
|
||||
```
|
||||
|
||||
**用途:** 列表展示时将数据库值转换为可读名称。
|
||||
|
||||
### getValue — 根据名称反向获取值
|
||||
|
||||
```php
|
||||
DictService::getValue('gender', '男'); // '1'
|
||||
DictService::getValue('user_status', '启用'); // '1'
|
||||
DictService::getValue('gender', ''); // ''(空值返回空字符串)
|
||||
```
|
||||
|
||||
**用途:** Excel 导入时将中文文字转换为数据库存储的数值。
|
||||
|
||||
### getOptions — 获取下拉框选项
|
||||
|
||||
```php
|
||||
DictService::getOptions('gender');
|
||||
// [
|
||||
// ['label' => '男', 'value' => '1'],
|
||||
// ['label' => '女', 'value' => '2'],
|
||||
// ]
|
||||
```
|
||||
|
||||
**用途:** 前端下拉框、单选框、复选框组件的数据源。
|
||||
|
||||
### getFullItems — 获取完整字典项
|
||||
|
||||
```php
|
||||
DictService::getFullItems('gender');
|
||||
// [
|
||||
// ['id' => 1, 'name' => '男', 'value' => '1', 'sort' => 1, 'note' => ''],
|
||||
// ['id' => 2, 'name' => '女', 'value' => '2', 'sort' => 2, 'note' => ''],
|
||||
// ]
|
||||
```
|
||||
|
||||
**用途:** 需要 id、排序、备注等完整信息的场景。
|
||||
|
||||
### clearCache — 清除缓存
|
||||
|
||||
```php
|
||||
DictService::clearCache('gender'); // 清除指定字典的缓存
|
||||
DictService::clearCache(); // 仅清空请求级缓存
|
||||
DictService::clearAllCache(); // 清除所有字典持久缓存
|
||||
```
|
||||
|
||||
## 方法清单
|
||||
|
||||
| 方法 | 说明 | 返回值 |
|
||||
|------|------|--------|
|
||||
| `getText($code, $value)` | 值 → 名称 | `string` |
|
||||
| `getValue($code, $text)` | 名称 → 值 | `string` |
|
||||
| `getItems($code)` | 获取值→名称映射 | `array` `['1'=>'男', '2'=>'女']` |
|
||||
| `getOptions($code)` | 获取下拉框选项 | `array` `[['label'=>'男','value'=>'1'], ...]` |
|
||||
| `getFullItems($code)` | 获取完整字典项 | `array` `[{id, name, value, sort, note}, ...]` |
|
||||
| `clearCache($code?)` | 清除指定/请求级缓存 | `void` |
|
||||
| `clearAllCache()` | 清除所有字典缓存 | `void` |
|
||||
|
||||
## 在 Logic 中使用
|
||||
|
||||
### 自动翻译(serializeMaps)
|
||||
|
||||
配置 `serializeMaps` 后,查询数据时自动补全 `{字段名}Text` 字段,无需手动调用 DictService:
|
||||
|
||||
```php
|
||||
class UserLogic extends BaseTenantLogic
|
||||
{
|
||||
/**
|
||||
* 枚举显示名映射
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $serializeMaps = [
|
||||
'gender' => 'gender',
|
||||
'status' => 'user_status',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
**效果:**
|
||||
|
||||
```text
|
||||
数据库:{ gender: 1, status: 1 }
|
||||
│
|
||||
▼ processSerializeMaps()
|
||||
│ DictService::getText('gender', 1) → '男'
|
||||
│ DictService::getText('user_status', 1) → '启用'
|
||||
▼
|
||||
前端接收:{ gender: 1, genderText: '男', status: 1, statusText: '启用' }
|
||||
```
|
||||
|
||||
### 手动调用
|
||||
|
||||
```php
|
||||
// 在 Logic 或 Service 中手动调用
|
||||
$genderText = DictService::getText('gender', $user['gender']);
|
||||
$options = DictService::getOptions('gender');
|
||||
```
|
||||
|
||||
## 在 Controller 中使用
|
||||
|
||||
### 获取下拉框数据
|
||||
|
||||
```php
|
||||
// app/controller/DictController.php
|
||||
|
||||
/**
|
||||
* 获取字典项下拉列表
|
||||
*
|
||||
* GET /dict/getOptions/{code}
|
||||
*/
|
||||
public function getOptions(string $code): Json
|
||||
{
|
||||
$options = DictService::getOptions($code);
|
||||
return $this->success($options);
|
||||
}
|
||||
```
|
||||
|
||||
### 刷新字典缓存
|
||||
|
||||
```php
|
||||
/**
|
||||
* 刷新字典缓存
|
||||
*
|
||||
* GET /dict/refreshCache
|
||||
*/
|
||||
public function refreshCache(): Json
|
||||
{
|
||||
DictService::clearAllCache();
|
||||
return $this->success(null, '缓存刷新成功');
|
||||
}
|
||||
```
|
||||
|
||||
## 在导入导出中使用
|
||||
|
||||
### 导入时:文字转值
|
||||
|
||||
```php
|
||||
// UserLogic::import()
|
||||
foreach ($data as &$row) {
|
||||
// Excel 中"男" → 数据库中 1
|
||||
if (!empty($row['gender']) && !is_numeric($row['gender'])) {
|
||||
$row['gender'] = DictService::getValue('gender', $row['gender']);
|
||||
}
|
||||
// Excel 中"启用" → 数据库中 1
|
||||
if (!empty($row['status']) && !is_numeric($row['status'])) {
|
||||
$row['status'] = DictService::getValue('user_status', $row['status']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 导出时:值转文字
|
||||
|
||||
```php
|
||||
// 导出表头使用 Text 后缀字段
|
||||
$headers = [
|
||||
'name' => '姓名',
|
||||
'genderText' => '性别', // 自动翻译后的字段
|
||||
'statusText' => '状态', // 自动翻译后的字段
|
||||
];
|
||||
```
|
||||
|
||||
## 前端集成
|
||||
|
||||
### 获取字典选项
|
||||
|
||||
```typescript
|
||||
// API
|
||||
export function getDictOptions(code: string) {
|
||||
return http.request({ url: `/dict/getOptions/${code}`, method: 'GET' });
|
||||
}
|
||||
|
||||
// 使用
|
||||
const genderOptions = ref([]);
|
||||
onMounted(async () => {
|
||||
genderOptions.value = await getDictOptions('gender');
|
||||
});
|
||||
```
|
||||
|
||||
### 下拉框组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-select v-model="form.gender" placeholder="请选择性别">
|
||||
<el-option
|
||||
v-for="item in genderOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 数据库表结构
|
||||
|
||||
### think_dict(字典主表)
|
||||
|
||||
| 字段 | 类型 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `id` | INT | 主键 | 1 |
|
||||
| `name` | VARCHAR(50) | 字典名称 | 性别 |
|
||||
| `code` | VARCHAR(50) | 字典编码(唯一) | gender |
|
||||
| `remark` | VARCHAR(500) | 备注 | 性别字典 |
|
||||
| `...` | | 公共字段 | |
|
||||
|
||||
### think_dict_item(字典项表)
|
||||
|
||||
| 字段 | 类型 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `id` | INT | 主键 | 1 |
|
||||
| `dict_id` | INT | 字典 ID(外键) | 1 |
|
||||
| `name` | VARCHAR(50) | 项名称 | 男 |
|
||||
| `value` | VARCHAR(50) | 项值 | 1 |
|
||||
| `sort` | INT | 排序 | 1 |
|
||||
| `note` | VARCHAR(200) | 备注 | |
|
||||
| `...` | | 公共字段 | |
|
||||
|
||||
## 字典编码规范
|
||||
|
||||
| 字典编码 | 名称 | 项值 | 用途 |
|
||||
|----------|------|------|------|
|
||||
| `gender` | 性别 | 0=女, 1=男 | 用户性别 |
|
||||
| `user_status` | 用户状态 | 0=禁用, 1=启用 | 用户账号状态 |
|
||||
| `article_status` | 文章状态 | 0=草稿, 1=已发布 | 文章状态 |
|
||||
| `tenant_status` | 租户状态 | 0=禁用, 1=启用 | 租户状态 |
|
||||
| `example_type` | 案例类型 | 0=类型一, 1=类型二 | 案例分类 |
|
||||
| `example_status` | 案例状态 | 0=禁用, 1=启用 | 案例状态 |
|
||||
|
||||
## 缓存管理
|
||||
|
||||
### 缓存失效时机
|
||||
|
||||
| 时机 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| 字典项新增/修改/删除 | `DictService::clearCache($code)` | 清除该字典的缓存 |
|
||||
| 手动刷新 | `GET /dict/refreshCache` | 清除所有字典缓存 |
|
||||
| 缓存过期 | 自动失效 | 1 小时 TTL |
|
||||
|
||||
:::warning 字典数据变更后必须刷新缓存
|
||||
修改字典项后,如果不刷新缓存,前端显示的可能仍是旧数据。建议在字典管理的增删改接口中自动调用 `clearCache()`。
|
||||
:::
|
||||
|
||||
## 安全特性
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 两级缓存 | 请求级 + 持久缓存,减少数据库压力 |
|
||||
| 空值安全 | `getText`/`getValue` 对 null/空字符串返回空字符串 |
|
||||
| 类型安全 | 值统一转为字符串比较,避免类型松散比较问题 |
|
||||
| 缓存隔离 | 每个字典编码独立缓存,互不影响 |
|
||||
@@ -0,0 +1,77 @@
|
||||
# 5.16 异常与响应处理
|
||||
|
||||
:::tip 概述
|
||||
ExceptionHandle 将所有异常统一渲染为 JSON 响应,保证前端始终拿到结构一致的错误信息。
|
||||
:::
|
||||
|
||||
## 响应结构
|
||||
|
||||
```json
|
||||
// 成功
|
||||
{"code": 0, "ok": true, "msg": "操作成功", "data": {...}}
|
||||
|
||||
// 失败
|
||||
{"code": 1, "ok": false, "msg": "操作失败", "data": null}
|
||||
```
|
||||
|
||||
## 异常处理优先级
|
||||
|
||||
```php
|
||||
// app/ExceptionHandle.php
|
||||
|
||||
public function render($request, Throwable $e): Response
|
||||
{
|
||||
// 1. 参数验证失败
|
||||
if ($e instanceof ValidateException) {
|
||||
return json(['code' => 1, 'ok' => false, 'msg' => '参数验证失败', 'data' => $e->getError()]);
|
||||
}
|
||||
|
||||
// 2. 数据不存在
|
||||
if ($e instanceof ModelNotFoundException || $e instanceof DataNotFoundException) {
|
||||
return json(['code' => 1, 'ok' => false, 'msg' => '数据不存在']);
|
||||
}
|
||||
|
||||
// 3. JWT 异常(code=401)
|
||||
if ($e instanceof \RuntimeException && in_array($e->getCode(), [401])) {
|
||||
return json(['code' => 1, 'ok' => false, 'msg' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
// 4. HTTP 异常
|
||||
if ($e instanceof HttpException) {
|
||||
return json(['code' => 1, 'ok' => false, 'msg' => $e->getMessage() ?: '请求错误']);
|
||||
}
|
||||
|
||||
// 5. 其他未知异常(生产环境隐藏详情)
|
||||
$msg = config('app.show_error_msg') ? $e->getMessage() : '操作失败';
|
||||
return json(['code' => 1, 'ok' => false, 'msg' => $msg]);
|
||||
}
|
||||
```
|
||||
|
||||
:::danger 生产环境安全
|
||||
`SHOW_ERROR_MSG` 必须设为 `false`,否则未知异常的详细信息(可能包含 SQL 语句、文件路径等敏感信息)会暴露给前端。
|
||||
:::
|
||||
|
||||
## 统一响应类(Result)
|
||||
|
||||
```php
|
||||
// extend/response/Result.php
|
||||
|
||||
Result::success($data, $msg); // 成功响应
|
||||
Result::page($records, $total, ...); // 分页响应
|
||||
Result::fail($msg, $code); // 失败响应
|
||||
Result::unauthorized($msg); // 401 未授权
|
||||
Result::forbidden($msg); // 403 禁止访问
|
||||
Result::notFound($msg); // 404 数据不存在
|
||||
Result::validateError($errors); // 422 验证错误
|
||||
```
|
||||
|
||||
## 状态码说明
|
||||
|
||||
| code | 含义 | 使用场景 |
|
||||
|------|------|----------|
|
||||
| 0 | 成功 | 所有成功操作 |
|
||||
| 1 | 一般失败 | 业务逻辑失败 |
|
||||
| 401 | 未授权 | Token 缺失/无效/过期 |
|
||||
| 403 | 禁止访问 | 权限不足 / 租户异常 / 演示环境 |
|
||||
| 404 | 数据不存在 | 查询单条记录为空 |
|
||||
| 422 | 参数验证失败 | 表单验证不通过 |
|
||||
@@ -0,0 +1,151 @@
|
||||
# 5.17 防重复提交
|
||||
|
||||
:::tip 概述
|
||||
防重复提交是保障数据一致性的重要措施。通过前端防抖和后端唯一性校验双重保护,防止用户重复点击导致的数据重复提交。
|
||||
:::
|
||||
|
||||
## 前端防抖
|
||||
|
||||
### 按钮 loading
|
||||
|
||||
ElementPlus 的按钮组件支持 `loading` 属性,请求期间禁用按钮:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
提交
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const submitting = ref(false);
|
||||
|
||||
async function handleSubmit() {
|
||||
submitting.value = true;
|
||||
try {
|
||||
await exampleAdd(formData.value);
|
||||
ElMessage.success('添加成功');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### useLockFn 钩子
|
||||
|
||||
前端提供 `useLockFn` 钩子函数,实现请求级别的防重复提交:
|
||||
|
||||
```typescript
|
||||
import { useLockFn } from '@/utils/lock';
|
||||
|
||||
// 包装提交函数,防止重复点击
|
||||
const handleSubmit = useLockFn(async () => {
|
||||
await exampleAdd(formData.value);
|
||||
ElMessage.success('添加成功');
|
||||
});
|
||||
```
|
||||
|
||||
`useLockFn` 原理:
|
||||
|
||||
```typescript
|
||||
function useLockFn(fn: Function) {
|
||||
let isLocked = false;
|
||||
return async (...args: any[]) => {
|
||||
if (isLocked) return;
|
||||
isLocked = true;
|
||||
try {
|
||||
return await fn(...args);
|
||||
} finally {
|
||||
isLocked = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 后端唯一性校验
|
||||
|
||||
### Logic 层唯一性配置
|
||||
|
||||
通过 `uniqueFields` 属性声明唯一性校验字段:
|
||||
|
||||
```php
|
||||
class ExampleLogic extends BaseLogic
|
||||
{
|
||||
/**
|
||||
* 写法一:单字段全局唯一
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $uniqueFields = ['name'];
|
||||
|
||||
/**
|
||||
* 写法二:混合配置(全局唯一 + 分组唯一)
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $uniqueFields = [
|
||||
'name',
|
||||
['code', 'pid'],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
BaseLogic 在 add/update 时自动校验,重复则抛出异常:
|
||||
|
||||
```php
|
||||
// BaseLogic::checkUnique()
|
||||
protected function checkUnique(array $data, ?int $excludeId = null): void
|
||||
{
|
||||
foreach ($this->uniqueFields as $rule) {
|
||||
if (is_string($rule)) {
|
||||
$field = $rule;
|
||||
$groupField = null;
|
||||
} else {
|
||||
$field = $rule[0];
|
||||
$groupField = $rule[1] ?? null;
|
||||
}
|
||||
|
||||
if (!isset($data[$field]) || $data[$field] === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$query = $this->getModel()->where($field, $data[$field]);
|
||||
|
||||
if ($groupField && isset($data[$groupField])) {
|
||||
$query = $query->where($groupField, $data[$groupField]);
|
||||
}
|
||||
|
||||
if ($excludeId !== null) {
|
||||
$query = $query->where('id', '<>', $excludeId);
|
||||
}
|
||||
|
||||
if ($query->find()) {
|
||||
$label = $this->getFieldLabel($field);
|
||||
throw new \Exception($label . '已存在');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 数据库唯一索引
|
||||
|
||||
软删除场景下,唯一索引需要特殊处理:
|
||||
|
||||
```sql
|
||||
-- MySQL:唯一索引包含 is_delete 字段
|
||||
ALTER TABLE think_example ADD UNIQUE INDEX uk_name (name, is_delete);
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
```text
|
||||
1. 前端防抖:按钮 loading 状态控制,防止用户重复点击
|
||||
2. 后端校验:Logic uniqueFields + 数据库唯一索引,双重保障
|
||||
3. 幂等设计:相同请求多次执行结果一致
|
||||
4. 错误提示:重复提交时返回明确的错误提示("xxx已存在")
|
||||
```
|
||||
@@ -0,0 +1,330 @@
|
||||
# 5.1 用户认证(JWT)
|
||||
|
||||
:::tip 概述
|
||||
JWT(JSON Web Token)是系统的核心认证机制。采用双令牌设计:access_token 用于接口认证(2小时),refresh_token 用于令牌刷新(7天)。无状态、可水平扩展,前后端分离场景下的标准方案。
|
||||
:::
|
||||
|
||||
## 双令牌机制
|
||||
|
||||
```text
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ 用户登录 │──── username ────►│ │
|
||||
│ │──── password ────►│JwtService│
|
||||
│ │──── code/key ────►│ .login()│
|
||||
└──────────┘ └────┬─────┘
|
||||
│
|
||||
┌─────────────────┼─────────────────┐
|
||||
▼ ▼ ▼
|
||||
access_token refresh_token user info
|
||||
(2小时有效) (7天有效) (基础信息)
|
||||
```
|
||||
|
||||
### 两种令牌的区别
|
||||
|
||||
| 特性 | access_token | refresh_token |
|
||||
|------|-------------|---------------|
|
||||
| 用途 | 接口认证(每个 API 请求携带) | 刷新令牌(仅在 access_token 过期时使用) |
|
||||
| 有效期 | 2 小时(7200 秒) | 7 天(604800 秒) |
|
||||
| payload.type | `access` | `refresh` |
|
||||
| 传递方式 | `Authorization: Bearer <token>` | POST `/oauth2/token` 请求体 |
|
||||
| 校验方 | AuthMiddleware | JwtService::refreshToken |
|
||||
|
||||
### 令牌流转全生命周期
|
||||
|
||||
```text
|
||||
1. 登录
|
||||
POST /api/login {username, password, code, key}
|
||||
│
|
||||
▼
|
||||
JwtService::login()
|
||||
│ 签发 access_token + refresh_token
|
||||
▼
|
||||
前端存储到 localStorage
|
||||
|
||||
2. 请求 API
|
||||
GET /api/user/page
|
||||
Authorization: Bearer <access_token>
|
||||
│
|
||||
▼
|
||||
AuthMiddleware::handle()
|
||||
│ 解析 → 校验签名 → 校验有效期 → 校验 type=access
|
||||
│ 注入 $request->userInfo
|
||||
▼
|
||||
Controller → Logic → Model → 响应
|
||||
|
||||
3. Token 过期
|
||||
前端响应拦截器检测 code=401
|
||||
│
|
||||
▼
|
||||
POST /api/oauth2/token {grant_type: refresh_token, refresh_token: <refresh_token>}
|
||||
│
|
||||
▼
|
||||
JwtService::refreshToken()
|
||||
│ 校验 type=refresh → 校验用户状态 → 签发新令牌对
|
||||
▼
|
||||
前端更新 localStorage 中的令牌
|
||||
|
||||
4. 登出
|
||||
前端清除 localStorage 中的令牌
|
||||
(JWT 无状态,服务端无需处理)
|
||||
```
|
||||
|
||||
## 配置项
|
||||
|
||||
| 配置键 | 环境变量 | 默认值 | 说明 |
|
||||
|--------|---------|--------|------|
|
||||
| `jwt.secret` | `JWT.SECRET` | 内置默认密钥 | JWT 签名密钥,**生产环境务必修改** |
|
||||
| `jwt.algo` | — | `HS256` | 签名算法 |
|
||||
| `jwt.access_ttl` | `JWT.ACCESS_TTL` | `7200` | access_token 有效期(秒),2 小时 |
|
||||
| `jwt.refresh_ttl` | `JWT.REFRESH_TTL` | `604800` | refresh_token 有效期(秒),7 天 |
|
||||
| `jwt.issuer` | — | `rxthinkcmf` | 签发者标识(iss 字段) |
|
||||
| `jwt.header_name` | — | `Authorization` | 请求头名称 |
|
||||
| `jwt.header_prefix` | — | `Bearer ` | 请求头前缀 |
|
||||
|
||||
:::warning 密钥安全
|
||||
生产环境务必通过 `.env` 设置 `JWT.SECRET` 为强随机密钥:
|
||||
```bash
|
||||
php -r "echo bin2hex(random_bytes(32));"
|
||||
```
|
||||
:::
|
||||
|
||||
## 核心类说明
|
||||
|
||||
### Jwt 工具类(extend/jwt/Jwt.php)
|
||||
|
||||
对 `firebase/php-jwt` 的薄封装,只做令牌的编解码与提取,不涉及业务逻辑。
|
||||
|
||||
| 方法 | 说明 | 调用方 |
|
||||
|------|------|--------|
|
||||
| `encode($payload, $ttl)` | 签发 Token(自动补齐 iss/iat/exp) | 内部方法 |
|
||||
| `decode($token)` | 解码 Token(校验签名+有效期) | AuthMiddleware、JwtService |
|
||||
| `getTokenFromHeader()` | 从请求头提取 Token | AuthMiddleware |
|
||||
| `createAccessToken($uid, $username)` | 签发 access_token | JwtService |
|
||||
| `createRefreshToken($uid, $username)` | 签发 refresh_token | JwtService |
|
||||
|
||||
**异常处理:** `decode()` 将 firebase 的具体异常统一转为 `RuntimeException`(code=401):
|
||||
|
||||
| 原始异常 | 转换后消息 |
|
||||
|---------|-----------|
|
||||
| `ExpiredException` | token已过期 |
|
||||
| `SignatureInvalidException` | token签名无效 |
|
||||
| 其他异常 | token无效: 原始信息 |
|
||||
|
||||
### JwtService 服务类(app/service/JwtService.php)
|
||||
|
||||
负责登录业务流程,调用 Jwt 工具类签发与刷新令牌。
|
||||
|
||||
| 方法 | 说明 | 返回值 |
|
||||
|------|------|--------|
|
||||
| `login($username, $password)` | 用户登录,签发令牌对 | `['access_token', 'refresh_token', 'token_type', 'expires_in', 'user']` |
|
||||
| `refreshToken($refreshToken)` | 用 refresh_token 换新令牌对 | `['access_token', 'refresh_token', 'token_type', 'expires_in']` |
|
||||
| `parseToken($token)` | 解析 access_token 获取 payload | `object`(含 uid、username、exp) |
|
||||
| `checkToken($token)` | 检测令牌有效性(不抛异常) | `['valid' => true/false, ...]` |
|
||||
| `revokeToken($token)` | 吊销令牌(当前简化实现) | `bool` |
|
||||
|
||||
## Token Payload 结构
|
||||
|
||||
### access_token payload
|
||||
|
||||
```json
|
||||
{
|
||||
"uid": 1,
|
||||
"username": "admin",
|
||||
"type": "access",
|
||||
"iss": "rxthinkcmf",
|
||||
"iat": 1727000000,
|
||||
"exp": 1727007200
|
||||
}
|
||||
```
|
||||
|
||||
### refresh_token payload
|
||||
|
||||
```json
|
||||
{
|
||||
"uid": 1,
|
||||
"username": "admin",
|
||||
"type": "refresh",
|
||||
"iss": "rxthinkcmf",
|
||||
"iat": 1727000000,
|
||||
"exp": 1727604800
|
||||
}
|
||||
```
|
||||
|
||||
## 登录流程详解
|
||||
|
||||
```text
|
||||
POST /api/login
|
||||
{ "username": "admin", "password": "123456", "code": "a3Bx", "key": "xxx" }
|
||||
│
|
||||
▼
|
||||
1. CaptchaService::check($code, $key)
|
||||
│ 验证码错误 → {"code":1, "msg":"验证码错误"}
|
||||
▼
|
||||
2. User::withoutGlobalScope(['soft_delete'])->where('username', $username)->find()
|
||||
│ 不存在 → {"code":1, "msg":"用户名或密码错误"}
|
||||
▼
|
||||
3. $user->status != 1
|
||||
│ 禁用 → {"code":1, "msg":"账号已被禁用"}
|
||||
▼
|
||||
4. PasswordService::verify($password, $user->password, $user->salt)
|
||||
│ 不匹配 → {"code":1, "msg":"用户名或密码错误"}
|
||||
▼
|
||||
5. Jwt::createAccessToken($uid, $username) + Jwt::createRefreshToken(...)
|
||||
│
|
||||
▼
|
||||
6. 返回
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "登录成功",
|
||||
"data": {
|
||||
"accessToken": "eyJ...",
|
||||
"refreshToken": "eyJ...",
|
||||
"tokenType": "Bearer",
|
||||
"expiresIn": 7200,
|
||||
"user": { "id": 1, "username": "admin", "realname": "管理员", "avatar": "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
:::tip 安全设计
|
||||
- 用户不存在和密码错误统一提示"用户名或密码错误",避免暴露账号是否存在
|
||||
- 验证码校验在密码校验之前,防止暴力破解
|
||||
- 已删除用户通过 `withoutGlobalScope` 查询但不返回,统一走"用户名或密码错误"
|
||||
:::
|
||||
|
||||
## Token 解析流程(AuthMiddleware)
|
||||
|
||||
```text
|
||||
请求进入 AuthMiddleware
|
||||
│
|
||||
▼
|
||||
1. 检查排除路由(login/captcha/oauth2 等直接放行)
|
||||
│
|
||||
▼
|
||||
2. Jwt::getTokenFromHeader()
|
||||
│ 从 Authorization 头提取 Token
|
||||
│ 空 → {"code":401, "msg":"未提供认证令牌"}
|
||||
▼
|
||||
3. Jwt::decode($token)
|
||||
│ 过期 → {"code":401, "msg":"token已过期"}
|
||||
│ 签名无效 → {"code":401, "msg":"token签名无效"}
|
||||
▼
|
||||
4. 校验 $decoded->type === 'access'
|
||||
│ 非 access → {"code":401, "msg":"无效的认证令牌"}
|
||||
▼
|
||||
5. $request->userInfo = $decoded
|
||||
│ 注入用户信息(uid、username 等)
|
||||
▼
|
||||
6. 读取 #[Permission] 注解 → 校验权限
|
||||
│ 无权限 → {"code":403, "msg":"无访问权限"}
|
||||
▼
|
||||
7. 放行 → $next($request)
|
||||
```
|
||||
|
||||
## Token 刷新流程
|
||||
|
||||
```text
|
||||
POST /api/oauth2/token
|
||||
{ "grant_type": "refresh_token", "refresh_token": "eyJ..." }
|
||||
│
|
||||
▼
|
||||
1. Jwt::decode($refreshToken)
|
||||
│ 过期/无效 → {"code":1, "msg":"无效的refresh_token"}
|
||||
▼
|
||||
2. 校验 $decoded->type === 'refresh'
|
||||
│ 非 refresh → {"code":1, "msg":"无效的refresh_token"}
|
||||
▼
|
||||
3. User::find($decoded->uid)
|
||||
│ 不存在或禁用 → {"code":1, "msg":"用户不存在或已被禁用"}
|
||||
▼
|
||||
4. 签发新 access_token + 新 refresh_token
|
||||
│
|
||||
▼
|
||||
5. 返回新令牌对
|
||||
```
|
||||
|
||||
:::tip "刷新即换新"策略
|
||||
刷新时旧 refresh_token 不显式作废,新旧令牌在各自有效期内都可用。如需一次性刷新(旧令牌刷新后立即失效),应结合 Redis 黑名单机制。
|
||||
:::
|
||||
|
||||
## 前端集成
|
||||
|
||||
### 登录与存储
|
||||
|
||||
```typescript
|
||||
// 登录
|
||||
const response = await login({ username, password, code, key });
|
||||
localStorage.setItem('access_token', response.data.accessToken);
|
||||
localStorage.setItem('refresh_token', response.data.refreshToken);
|
||||
```
|
||||
|
||||
### 请求拦截器(自动携带 Token)
|
||||
|
||||
```typescript
|
||||
// src/utils/http/axios/index.ts
|
||||
axios.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
```
|
||||
|
||||
### 响应拦截器(自动刷新 Token)
|
||||
|
||||
```typescript
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
async error => {
|
||||
const { config, response } = error;
|
||||
|
||||
if (response?.status === 401 && !config._retry) {
|
||||
config._retry = true;
|
||||
|
||||
// 尝试用 refresh_token 刷新
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const res = await refreshTokenApi(refreshToken);
|
||||
localStorage.setItem('access_token', res.data.accessToken);
|
||||
localStorage.setItem('refresh_token', res.data.refreshToken);
|
||||
|
||||
// 用新 Token 重试原请求
|
||||
config.headers['Authorization'] = `Bearer ${res.data.accessToken}`;
|
||||
return axios(config);
|
||||
} catch (e) {
|
||||
// 刷新失败,跳转登录
|
||||
localStorage.clear();
|
||||
router.push('/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## 安全建议
|
||||
|
||||
| 建议 | 说明 |
|
||||
|------|------|
|
||||
| 生产环境修改密钥 | `JWT.SECRET` 使用 32 字节以上随机字符串 |
|
||||
| HTTPS 传输 | Token 明文传输,必须使用 HTTPS |
|
||||
| 合理设置有效期 | access_token 不宜过长(2小时),refresh_token 按需调整 |
|
||||
| 敏感操作二次验证 | 修改密码、删除数据等操作建议额外校验 |
|
||||
| Token 黑名单 | 生产环境建议用 Redis 实现 Token 吊销(当前为简化实现) |
|
||||
| 前端安全存储 | 避免将 Token 存入 Cookie(防 CSRF),推荐 localStorage |
|
||||
|
||||
## 错误响应汇总
|
||||
|
||||
| 场景 | HTTP 状态 | code | msg |
|
||||
|------|----------|------|-----|
|
||||
| 未提供 Token | 200 | 401 | 未提供认证令牌 |
|
||||
| Token 过期 | 200 | 401 | token已过期 |
|
||||
| Token 签名无效 | 200 | 401 | token签名无效 |
|
||||
| Token 类型错误 | 200 | 401 | 无效的认证令牌 |
|
||||
| 无权限 | 200 | 403 | 无访问权限 |
|
||||
| refresh_token 无效 | 200 | 1 | 无效的refresh_token |
|
||||
| 用户被禁用 | 200 | 1 | 用户不存在或已被禁用 |
|
||||
@@ -0,0 +1,255 @@
|
||||
# 5.15 登录日志
|
||||
|
||||
:::tip 概述
|
||||
登录日志记录用户的登录/登出行为,包括成功和失败记录。每次登录尝试都会记录,便于安全审计和异常行为分析。与操作日志不同,登录日志由 LoginLogic 手动记录,而非中间件自动记录。
|
||||
:::
|
||||
|
||||
## 记录流程
|
||||
|
||||
```text
|
||||
用户提交登录
|
||||
│
|
||||
▼
|
||||
LoginLogic::login()
|
||||
│
|
||||
├─ 创建 RequestInfoService(解析 IP、UA、归属地)
|
||||
│
|
||||
├─ 验证验证码 CaptchaService::check()
|
||||
│ └─ 失败 → 记录登录日志(status=0, error="验证码错误")
|
||||
│
|
||||
├─ 调用 JwtService::login()
|
||||
│ ├─ 查询用户 → 不存在 → 记录登录日志(status=0, error="用户名或密码错误")
|
||||
│ ├─ 校验状态 → 禁用 → 记录登录日志(status=0, error="账号已被禁用")
|
||||
│ ├─ 校验密码 → 错误 → 记录登录日志(status=0, error="用户名或密码错误")
|
||||
│ └─ 签发 Token → 成功
|
||||
│
|
||||
├─ 记录登录日志(status=1, 无错误)
|
||||
│
|
||||
└─ 返回 { access_token, refresh_token, user }
|
||||
```
|
||||
|
||||
### 登录/登出记录
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 登录成功 │
|
||||
│ LoginLogic::login() → 成功 → recordLoginLog(username, 1, 0) │
|
||||
│ LoginController::login() → 成功日志 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 登录失败 │
|
||||
│ LoginLogic::login() → 异常 → recordLoginLog(username, 0, 1) │
|
||||
│ LoginController::login() → 失败日志 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 登出 │
|
||||
│ LoginController::logout() → recordLoginLog(username, 2, 0) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 记录字段
|
||||
|
||||
| 字段 | 类型 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `username` | VARCHAR(50) | 用户名(即使登录失败也记录) | admin |
|
||||
| `type` | TINYINT | 类型:1=登录 2=登出 | 1 |
|
||||
| `status` | TINYINT | 状态:0=失败 1=成功 | 1 |
|
||||
| `message` | VARCHAR(200) | 结果消息 | 登录成功 / 密码错误 |
|
||||
| `ip` | VARCHAR(50) | 客户端 IP | 192.168.1.100 |
|
||||
| `location` | VARCHAR(100) | IP 归属地 | 江苏省南京市 |
|
||||
| `os` | VARCHAR(100) | 操作系统 | Windows 10 |
|
||||
| `browser` | VARCHAR(100) | 浏览器 | Chrome 120 |
|
||||
| `create_time` | DATETIME | 登录时间 | 2026-09-22 10:30:00 |
|
||||
|
||||
## 核心代码
|
||||
|
||||
### 登录记录
|
||||
|
||||
```php
|
||||
// app/logic/LoginLogic.php
|
||||
|
||||
public function login(string $username, string $password, string $captchaKey, string $captchaCode): array
|
||||
{
|
||||
$requestInfo = RequestInfoService::create(request());
|
||||
|
||||
try {
|
||||
// 验证验证码
|
||||
if (!CaptchaService::check($captchaKey, $captchaCode)) {
|
||||
throw new \Exception('验证码错误');
|
||||
}
|
||||
|
||||
// 调用 JwtService 登录
|
||||
$result = $this->jwtService->login($username, $password);
|
||||
|
||||
// 记录成功日志
|
||||
$this->recordLoginLog($username, 1, 0, '', '登录成功');
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// 记录失败日志
|
||||
$this->recordLoginLog($username, 1, 1, $e->getMessage(), '登录失败');
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 统一记录方法
|
||||
|
||||
```php
|
||||
/**
|
||||
* 记录登录日志
|
||||
*
|
||||
* @param string $username 用户名
|
||||
* @param int $type 类型:1=登录 2=登出
|
||||
* @param int $status 状态:0=成功 1=失败
|
||||
* @param string $error 错误信息(成功时为空)
|
||||
* @param string $message 结果消息
|
||||
*/
|
||||
public function recordLoginLog(string $username, int $type, int $status, string $error = '', string $message = ''): void
|
||||
{
|
||||
$requestInfo = RequestInfoService::create(request());
|
||||
|
||||
try {
|
||||
LoginLog::create([
|
||||
'username' => $username,
|
||||
'type' => $type,
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'ip' => $requestInfo->getIp(),
|
||||
'location' => $requestInfo->getIpLocation(),
|
||||
'os' => $requestInfo->getOs(),
|
||||
'browser' => $requestInfo->getBrowser(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// 日志记录失败不影响业务
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 登出记录
|
||||
|
||||
```php
|
||||
// app/controller/LoginController.php
|
||||
|
||||
#[Log('系统登录-用户登出', Log::TYPE_LOGOUT)]
|
||||
public function logout(): Json
|
||||
{
|
||||
$token = \jwt\Jwt::getTokenFromHeader();
|
||||
if ($token) {
|
||||
$this->logic->recordLoginLog($this->getLoginUsername(), 2, 0, '', '退出成功');
|
||||
}
|
||||
return $this->success(null, '登出成功');
|
||||
}
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
| 方法 | 路径 | 权限码 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/login/log/page` | sys:login:log:list | 分页列表 |
|
||||
| GET | `/api/login/log/detail/:id` | sys:login:log:detail | 详情 |
|
||||
| DELETE | `/api/login/log/delete/:id` | sys:login:log:delete | 删除 |
|
||||
| DELETE | `/api/login/log/batchDelete` | sys:login:log:delete | 批量删除 |
|
||||
|
||||
## 安全分析
|
||||
|
||||
### 暴力破解检测
|
||||
|
||||
```sql
|
||||
-- 短时间内同一 IP 大量失败登录(5分钟内失败超过10次)
|
||||
SELECT ip, COUNT(*) as fail_count, MIN(create_time) as first_time, MAX(create_time) as last_time
|
||||
FROM think_login_log
|
||||
WHERE status = 0
|
||||
AND create_time > DATE_SUB(NOW(), INTERVAL 5 MINUTE)
|
||||
GROUP BY ip
|
||||
HAVING fail_count >= 10
|
||||
ORDER BY fail_count DESC;
|
||||
```
|
||||
|
||||
### 异常账号检测
|
||||
|
||||
```sql
|
||||
-- 同一账号短时间内大量失败(可能被暴力破解)
|
||||
SELECT username, COUNT(*) as fail_count
|
||||
FROM think_login_log
|
||||
WHERE status = 0
|
||||
AND create_time > DATE_SUB(NOW(), INTERVAL 1 HOUR)
|
||||
GROUP BY username
|
||||
HAVING fail_count >= 5
|
||||
ORDER BY fail_count DESC;
|
||||
```
|
||||
|
||||
### 异地登录检测
|
||||
|
||||
```sql
|
||||
-- 同一用户在不同城市短时间内登录
|
||||
SELECT username, location, COUNT(*) as login_count
|
||||
FROM think_login_log
|
||||
WHERE status = 1
|
||||
AND create_time > DATE_SUB(NOW(), INTERVAL 1 DAY)
|
||||
GROUP BY username, location
|
||||
HAVING login_count >= 1;
|
||||
```
|
||||
|
||||
### 高频恶意 IP
|
||||
|
||||
```sql
|
||||
-- 统计失败登录最多的 IP(最近7天)
|
||||
SELECT ip, location, COUNT(*) as fail_count
|
||||
FROM think_login_log
|
||||
WHERE status = 0
|
||||
AND create_time > DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
GROUP BY ip, location
|
||||
ORDER BY fail_count DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
### 成功率分析
|
||||
|
||||
```sql
|
||||
-- 最近30天登录成功率
|
||||
SELECT
|
||||
DATE(create_time) as date,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) as success,
|
||||
ROUND(SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) / COUNT(*) * 100, 2) as success_rate
|
||||
FROM think_login_log
|
||||
WHERE type = 1
|
||||
AND create_time > DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE(create_time)
|
||||
ORDER BY date;
|
||||
```
|
||||
|
||||
## 与操作日志的区别
|
||||
|
||||
| 维度 | 登录日志 | 操作日志 |
|
||||
|------|---------|---------|
|
||||
| 表 | `think_login_log` | `think_operation_log` |
|
||||
| 记录方 | LoginLogic 手动记录 | LogMiddleware 自动记录 |
|
||||
| 记录内容 | 仅登录/登出 | 所有写操作 + 导出/导入 |
|
||||
| 触发方式 | 代码中调用 `recordLoginLog()` | `#[Log]` 注解 |
|
||||
| 用途 | 登录安全审计 | 业务操作审计 |
|
||||
| 失败也记录 | ✓(登录失败必须记录) | ✗(仅记录成功的写操作) |
|
||||
|
||||
## 日志保留策略
|
||||
|
||||
| 策略 | 说明 | 建议保留时间 |
|
||||
|------|------|-------------|
|
||||
| 按时间清理 | 定期删除 N 天前的日志 | 180 天(6个月) |
|
||||
| 安全合规 | 某些行业要求保留更长时间 | 按合规要求 |
|
||||
|
||||
```sql
|
||||
-- 示例:清理 180 天前的登录日志
|
||||
DELETE FROM think_login_log WHERE create_time < DATE_SUB(NOW(), INTERVAL 180 DAY);
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
| 实践 | 说明 |
|
||||
|------|------|
|
||||
| 成功和失败都记录 | 安全审计需要完整的登录行为记录 |
|
||||
| 统一错误提示 | "用户名或密码错误"不区分用户不存在和密码错误 |
|
||||
| 定期分析 | 定期检查异常登录模式(暴力破解、异地登录) |
|
||||
| IP 黑名单 | 将高频恶意 IP 加入黑名单(Nginx/防火墙层) |
|
||||
| 配合验证码 | 验证码防刷 + 登录日志分析形成双重防护 |
|
||||
| 日志不可篡改 | 登录日志仅 INSERT,无 UPDATE 接口 |
|
||||
@@ -0,0 +1,323 @@
|
||||
# 5.14 操作日志
|
||||
|
||||
:::tip 概述
|
||||
操作日志通过 `#[Log]` 注解 + LogMiddleware 自动记录,无需手动埋点。所有写操作(POST/PUT/DELETE/PATCH)始终记录,GET 请求仅当注解类型为导出/下载/导入时记录。日志写入失败不影响主业务流程。
|
||||
:::
|
||||
|
||||
## 记录流程
|
||||
|
||||
```text
|
||||
请求进入
|
||||
│
|
||||
▼
|
||||
LogMiddleware::handle()
|
||||
│
|
||||
├─ 记录开始时间 microtime(true)
|
||||
│
|
||||
├─ 执行后续中间件和控制器($next($request))
|
||||
│
|
||||
├─ 获取响应
|
||||
│
|
||||
├─ 检查排除路由(config middleware.log_except)
|
||||
│ └─ 命中 → 直接返回,不记录
|
||||
│
|
||||
├─ 判断是否需要记录
|
||||
│ ├─ POST/PUT/DELETE/PATCH → 始终记录 ✓
|
||||
│ ├─ GET + #[Log] TYPE_EXPORT → 记录 ✓
|
||||
│ ├─ GET + #[Log] TYPE_DOWNLOAD → 记录 ✓
|
||||
│ ├─ GET + #[Log] TYPE_IMPORT → 记录 ✓
|
||||
│ └─ 其它 GET → 不记录 ✗
|
||||
│
|
||||
├─ 读取 #[Log] 注解(AttributeService::getLog)
|
||||
│ ├─ 有注解 → 使用注解的 title、type、description
|
||||
│ └─ 无注解 → 使用路径作为 title,按 HTTP 方法猜测 type
|
||||
│
|
||||
├─ 组装日志数据
|
||||
│ ├─ 标题、类型(来自注解)
|
||||
│ ├─ URL、请求参数(截取前 2000 字符)
|
||||
│ ├─ 响应内容(截取前 2000 字符)
|
||||
│ ├─ IP、归属地、操作系统、浏览器(RequestInfoService)
|
||||
│ ├─ 执行耗时 = (当前时间 - 开始时间) × 1000 毫秒
|
||||
│ └─ 状态(检测响应中的 code:0=正常 1=异常)、错误信息
|
||||
│
|
||||
└─ 写入 OperationLog 表(try-catch 包裹,失败不影响主业务)
|
||||
```
|
||||
|
||||
## 记录字段
|
||||
|
||||
| 字段 | 说明 | 来源 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `title` | 操作标题 | `#[Log]` 注解 title 参数 | 用户管理-新增记录 |
|
||||
| `type` | 操作类型 | `#[Log]` 注解 type 参数 | 1=新增 |
|
||||
| `source` | 来源 | 固定 0 | 0=后台 |
|
||||
| `method` | HTTP 方法 | 请求方法 | POST |
|
||||
| `request_method` | 控制器方法名 | 请求 action | add |
|
||||
| `url` | 请求 URL | 请求 url | /api/user/add |
|
||||
| `param` | 请求参数 | JSON 截取前 2000 字符 | {"username":"admin"} |
|
||||
| `result` | 响应内容 | JSON 截取前 2000 字符 | {"code":0,"msg":"添加成功"} |
|
||||
| `ip` | 客户端 IP | RequestInfoService | 192.168.1.100 |
|
||||
| `location` | IP 归属地 | zoujingli/ip2region | 江苏省南京市 |
|
||||
| `os` | 操作系统 | RequestInfoService | Windows 10 |
|
||||
| `browser` | 浏览器 | RequestInfoService | Chrome 120 |
|
||||
| `consume_time` | 执行耗时 | 毫秒 | 125 |
|
||||
| `status` | 状态 | 响应 code 判断 | 0=正常 1=异常 |
|
||||
| `error` | 错误信息 | 响应中的 msg(异常时) | 用户名已存在 |
|
||||
| `create_user` | 操作人 | 当前登录用户名 | admin |
|
||||
| `create_time` | 操作时间 | 自动填充 | 2026-09-22 10:30:00 |
|
||||
|
||||
## 注解使用
|
||||
|
||||
### 基本用法
|
||||
|
||||
```php
|
||||
// 写操作(POST/PUT/DELETE/PATCH)始终记录
|
||||
#[Log('用户管理-新增', Log::TYPE_ADD)]
|
||||
public function add(): Json { ... }
|
||||
|
||||
#[Log('用户管理-修改', Log::TYPE_UPDATE)]
|
||||
public function update(): Json { ... }
|
||||
|
||||
#[Log('用户管理-删除', Log::TYPE_DELETE)]
|
||||
public function delete(int $id): Json { ... }
|
||||
```
|
||||
|
||||
### 带描述占位符
|
||||
|
||||
```php
|
||||
#[Log('用户管理-删除记录', Log::TYPE_DELETE, '删除用户ID:{id}')]
|
||||
public function delete(int $id): Json { ... }
|
||||
|
||||
// 占位符 {id} 会被替换为 $request->param('id') 的值
|
||||
// 实际记录:"删除用户ID:42"
|
||||
|
||||
#[Log('用户管理-新增记录', Log::TYPE_ADD, '新增用户:{username}')]
|
||||
public function add(): Json { ... }
|
||||
|
||||
// 实际记录:"新增用户:admin"
|
||||
|
||||
#[Log('用户管理-批量删除', Log::TYPE_DELETE, '批量删除用户,ID列表:{ids}')]
|
||||
public function batchDelete(): Json { ... }
|
||||
|
||||
// 实际记录:"批量删除用户,ID列表:[1,2,3]"
|
||||
```
|
||||
|
||||
### 导出操作(GET 也会记录)
|
||||
|
||||
```php
|
||||
#[Log('用户管理-导出数据', Log::TYPE_EXPORT)]
|
||||
public function export(): Json { ... }
|
||||
|
||||
#[Log('用户管理-导入数据', Log::TYPE_IMPORT)]
|
||||
public function import(): Json { ... }
|
||||
```
|
||||
|
||||
:::warning GET 请求的记录策略
|
||||
GET 请求仅当注解类型为 `TYPE_EXPORT`、`TYPE_DOWNLOAD` 或 `TYPE_IMPORT` 时才会记录日志。普通查询(`TYPE_QUERY`)不会被记录,以避免大量查询淹没日志。
|
||||
:::
|
||||
|
||||
### 查询类型(GET 默认不记录)
|
||||
|
||||
```php
|
||||
// TYPE_QUERY 不会被记录(GET 请求默认不记录)
|
||||
#[Log('用户管理-查询分页', Log::TYPE_QUERY)]
|
||||
public function page(): Json { ... }
|
||||
|
||||
// 如果确实需要记录某个查询,可以使用 TYPE_OTHER
|
||||
#[Log('用户管理-敏感查询', Log::TYPE_OTHER)]
|
||||
public function sensitiveQuery(): Json { ... }
|
||||
```
|
||||
|
||||
### 与 Permission 注解组合
|
||||
|
||||
```php
|
||||
#[Log('用户管理-新增记录', Log::TYPE_ADD, '新增用户:{username}')]
|
||||
#[Permission('sys:user:add', '添加用户')]
|
||||
public function add(): Json { ... }
|
||||
|
||||
// 执行顺序:
|
||||
// 1. AuthMiddleware 校验权限(#[Permission])
|
||||
// 2. 权限通过后进入 Controller
|
||||
// 3. LogMiddleware 记录日志(#[Log])
|
||||
```
|
||||
|
||||
## 操作类型常量
|
||||
|
||||
| 常量 | 值 | 说明 | GET 是否记录 |
|
||||
|------|-----|------|-------------|
|
||||
| `Log::TYPE_ADD` | 1 | 新增 | — |
|
||||
| `Log::TYPE_UPDATE` | 2 | 修改 | — |
|
||||
| `Log::TYPE_DELETE` | 3 | 删除 | — |
|
||||
| `Log::TYPE_QUERY` | 4 | 查询 | ✗ |
|
||||
| `Log::TYPE_IMPORT` | 5 | 导入 | ✓ |
|
||||
| `Log::TYPE_EXPORT` | 6 | 导出 | ✓ |
|
||||
| `Log::TYPE_DOWNLOAD` | 7 | 下载 | ✓ |
|
||||
| `Log::TYPE_APPROVE` | 8 | 审批 | — |
|
||||
| `Log::TYPE_REJECT` | 9 | 驳回 | — |
|
||||
| `Log::TYPE_SUBMIT` | 10 | 提交 | — |
|
||||
| `Log::TYPE_WITHDRAW` | 11 | 撤回 | — |
|
||||
| `Log::TYPE_LOGIN` | 21 | 登录 | — |
|
||||
| `Log::TYPE_LOGOUT` | 22 | 登出 | — |
|
||||
| `Log::TYPE_RESET` | 24 | 重置 | — |
|
||||
| `Log::TYPE_OTHER` | 99 | 其他 | — |
|
||||
|
||||
完整常量列表见 `app/attribute/Log.php`(共 40 种)。
|
||||
|
||||
## API 接口
|
||||
|
||||
| 方法 | 路径 | 权限码 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/oper/log/page` | sys:oper:log:list | 分页列表 |
|
||||
| GET | `/api/oper/log/detail/:id` | sys:oper:log:detail | 详情 |
|
||||
| DELETE | `/api/oper/log/delete/:id` | sys:oper:log:delete | 删除 |
|
||||
| DELETE | `/api/oper/log/batchDelete` | sys:oper:log:delete | 批量删除 |
|
||||
|
||||
## 排除配置
|
||||
|
||||
```php
|
||||
// config/middleware.php
|
||||
'log_except' => [
|
||||
'login', // 登录日志单独记录在 login_log 表
|
||||
'logout',
|
||||
'captcha',
|
||||
'oauth2',
|
||||
'index/getMenus', // 获取菜单(频繁调用,不记录)
|
||||
'index/getUserInfo', // 获取用户信息
|
||||
],
|
||||
```
|
||||
|
||||
## 核心代码
|
||||
|
||||
```php
|
||||
// app/middleware/LogMiddleware.php
|
||||
|
||||
public function handle(Request $request, \Closure $next): Response
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$response = $next($request);
|
||||
|
||||
$path = $request->pathinfo();
|
||||
$exceptList = config('middleware.log_except', $this->except);
|
||||
|
||||
// 检查排除路由
|
||||
foreach ($exceptList as $except) {
|
||||
if (strpos($path, $except) !== false) {
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
$method = strtoupper($request->method());
|
||||
|
||||
// 获取注解信息
|
||||
$controllerClass = 'app\\controller\\' . $request->controller();
|
||||
$logAttr = AttributeService::getLog($controllerClass, $request->action());
|
||||
|
||||
// 判断是否需要记录
|
||||
$needLog = false;
|
||||
if (in_array($method, ['POST', 'PUT', 'DELETE', 'PATCH'])) {
|
||||
$needLog = true; // 写操作始终记录
|
||||
} elseif ($method === 'GET' && $logAttr) {
|
||||
$logType = $logAttr->type;
|
||||
if (in_array($logType, [Log::TYPE_EXPORT, Log::TYPE_DOWNLOAD, Log::TYPE_IMPORT])) {
|
||||
$needLog = true; // GET 仅记录导出/下载/导入
|
||||
}
|
||||
}
|
||||
|
||||
if (!$needLog) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
try {
|
||||
$consumeTime = round((microtime(true) - $startTime) * 1000);
|
||||
$userInfo = $request->userInfo ?? null;
|
||||
$requestInfo = RequestInfoService::create($request);
|
||||
|
||||
OperationLog::create([
|
||||
'title' => $logAttr?->title ?: $path,
|
||||
'type' => $logAttr?->type ?: $this->guessType($method),
|
||||
'method' => $method,
|
||||
'url' => $request->url(),
|
||||
'param' => mb_substr(json_encode($request->param()), 0, 2000),
|
||||
'result' => mb_substr($response->getContent(), 0, 2000),
|
||||
'ip' => $requestInfo->getIp(),
|
||||
'location' => $requestInfo->getIpLocation(),
|
||||
'os' => $requestInfo->getOs(),
|
||||
'browser' => $requestInfo->getBrowser(),
|
||||
'consume_time' => $consumeTime,
|
||||
'status' => $this->detectStatus($response->getContent()),
|
||||
'error' => $this->detectError($response->getContent()),
|
||||
'create_user' => $userInfo->username ?? '',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// 日志记录失败不影响业务
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
```
|
||||
|
||||
## 日志查询与分析
|
||||
|
||||
### 按操作人查询
|
||||
|
||||
```text
|
||||
GET /api/oper/log/page?createUser=admin&pageNo=1&pageSize=20
|
||||
```
|
||||
|
||||
### 按操作类型查询
|
||||
|
||||
```text
|
||||
GET /api/oper/log/page?type=3&pageNo=1&pageSize=20
|
||||
(查询所有删除操作)
|
||||
```
|
||||
|
||||
### 按时间范围查询
|
||||
|
||||
```text
|
||||
GET /api/oper/log/page?startTime=2026-09-01&endTime=2026-09-22
|
||||
```
|
||||
|
||||
### 按状态查询(查异常操作)
|
||||
|
||||
```text
|
||||
GET /api/oper/log/page?status=1
|
||||
(查询所有失败的操作)
|
||||
```
|
||||
|
||||
## 日志保留策略
|
||||
|
||||
| 策略 | 说明 | 适用场景 |
|
||||
|------|------|---------|
|
||||
| 按时间清理 | 定期删除 N 天前的日志 | 大多数场景 |
|
||||
| 按数量保留 | 只保留最近 N 条 | 日志量大 |
|
||||
| 归档到冷存储 | 导出到文件/对象存储 | 合规要求 |
|
||||
|
||||
```sql
|
||||
-- 示例:清理 90 天前的操作日志
|
||||
DELETE FROM think_operation_log WHERE create_time < DATE_SUB(NOW(), INTERVAL 90 DAY);
|
||||
|
||||
-- 示例:按月归档
|
||||
SELECT * FROM think_operation_log
|
||||
WHERE create_time >= '2026-09-01' AND create_time < '2026-10-01'
|
||||
INTO OUTFILE '/backup/operation_log_202609.csv';
|
||||
```
|
||||
|
||||
## 与登录日志的区别
|
||||
|
||||
| 维度 | 操作日志 | 登录日志 |
|
||||
|------|---------|---------|
|
||||
| 表 | `think_operation_log` | `think_login_log` |
|
||||
| 记录方 | LogMiddleware 自动记录 | LoginLogic 手动记录 |
|
||||
| 记录内容 | 所有写操作 + 导出/导入 | 仅登录/登出 |
|
||||
| 触发方式 | `#[Log]` 注解 | 代码中调用 `recordLoginLog()` |
|
||||
| 用途 | 业务操作审计 | 登录安全审计 |
|
||||
|
||||
## 最佳实践
|
||||
|
||||
| 实践 | 说明 |
|
||||
|------|------|
|
||||
| 标题语义化 | 使用"模块-操作"格式,如"用户管理-新增记录" |
|
||||
| 描述加关键参数 | 用 `{param}` 占位符记录业务主键,便于追溯 |
|
||||
| 敏感操作必须标注 | 删除、重置密码、权限变更等操作必须加 `#[Log]` |
|
||||
| 查询不记录 | 普通查询不加 `#[Log]` 或使用 `TYPE_QUERY`(不记录) |
|
||||
| 定期清理 | 避免日志表无限增长影响性能 |
|
||||
| 排除高频接口 | 将频繁调用的接口加入 `log_except` |
|
||||
@@ -0,0 +1,89 @@
|
||||
# 5.13 系统参数管理
|
||||
|
||||
:::tip 概述
|
||||
ParamService 管理系统级参数,如默认密码、系统名称等,采用两级缓存(请求级 + 持久缓存),大幅减少数据库查询。
|
||||
:::
|
||||
|
||||
## 缓存架构
|
||||
|
||||
```text
|
||||
请求级缓存(static::$cache) ← 同一请求内不重复查库
|
||||
│ 未命中
|
||||
▼
|
||||
持久缓存(cache(),1小时) ← 跨请求复用
|
||||
│ 未命中
|
||||
▼
|
||||
数据库查询(param 表) ← 查询后写入两级缓存
|
||||
```
|
||||
|
||||
## 核心方法
|
||||
|
||||
```php
|
||||
// app/service/ParamService.php
|
||||
|
||||
// 获取参数值(通用)
|
||||
$value = ParamService::get('DEFAULT_PASSWORD', '123456');
|
||||
|
||||
// 获取字符串参数
|
||||
$value = ParamService::getString('SITE_NAME', 'RXThinkCMF');
|
||||
|
||||
// 获取整数参数
|
||||
$value = ParamService::getInt('MAX_LOGIN_ATTEMPTS', 5);
|
||||
|
||||
// 获取布尔参数
|
||||
$value = ParamService::getBool('ENABLE_SMS', false);
|
||||
|
||||
// 获取默认密码(从系统参数读取,默认 '123456')
|
||||
$password = ParamService::getDefaultPassword();
|
||||
|
||||
// 清除指定参数缓存
|
||||
ParamService::clearCache('DEFAULT_PASSWORD');
|
||||
|
||||
// 清除所有参数缓存
|
||||
ParamService::clearAllCache();
|
||||
```
|
||||
|
||||
## 方法签名
|
||||
|
||||
| 方法 | 说明 | 返回值 |
|
||||
|------|------|--------|
|
||||
| `get($code, $default)` | 获取参数值 | mixed |
|
||||
| `getString($code, $default)` | 获取字符串 | string |
|
||||
| `getInt($code, $default)` | 获取整数 | int |
|
||||
| `getBool($code, $default)` | 获取布尔 | bool |
|
||||
| `getDefaultPassword()` | 获取默认密码明文 | string |
|
||||
| `clearCache($code)` | 清除指定缓存 | void |
|
||||
| `clearAllCache()` | 清除所有缓存 | void |
|
||||
|
||||
## 查询条件
|
||||
|
||||
仅查询满足以下条件的参数:
|
||||
- `status = 1`(启用)
|
||||
- `is_delete = 0`(未删除)
|
||||
|
||||
## API 接口
|
||||
|
||||
| 方法 | 路径 | 权限码 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/param/page` | sys:param:page | 分页列表 |
|
||||
| GET | `/api/param/list` | sys:param:list | 全量列表 |
|
||||
| GET | `/api/param/detail/:id` | sys:param:detail | 详情 |
|
||||
| POST | `/api/param/add` | sys:param:add | 新增 |
|
||||
| PUT | `/api/param/update` | sys:param:update | 修改 |
|
||||
| DELETE | `/api/param/delete/:id` | sys:param:delete | 删除 |
|
||||
| DELETE | `/api/param/batchDelete` | sys:param:batchDelete | 批量删除 |
|
||||
|
||||
## 与配置管理的区别
|
||||
|
||||
| 维度 | 参数管理(Param) | 配置管理(Config) |
|
||||
|------|------------------|-------------------|
|
||||
| 数据结构 | 独立的键值对 | 分组 + 配置项 |
|
||||
| 适用场景 | 系统级参数(默认密码、限流阈值) | 网站配置(名称、Logo、SMTP) |
|
||||
| 读取方式 | ParamService::get('code') | ConfigItemLogic 查询 |
|
||||
| 缓存 | 两级缓存 | 无特殊缓存 |
|
||||
|
||||
## 常用系统参数
|
||||
|
||||
| 编码 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `DEFAULT_PASSWORD` | 默认密码 | 123456 |
|
||||
@@ -0,0 +1,307 @@
|
||||
# 5.6 密码加密服务
|
||||
|
||||
:::tip 概述
|
||||
PasswordService 基于 bcrypt 实现双重加盐密码加密,全系统的密码加解密均通过本服务完成,包括登录校验、新建用户、重置密码、租户账号创建等场景。
|
||||
:::
|
||||
|
||||
## 加密方案
|
||||
|
||||
### 双重加盐原理
|
||||
|
||||
```text
|
||||
明文密码:"123456"
|
||||
│
|
||||
▼ 第一层:外部盐(salt 字段,10位随机字符串,每个用户独立)
|
||||
拼接:password + salt = "123456xK7pMn3qRt"
|
||||
│
|
||||
▼ 第二层:bcrypt 内部盐(自动嵌入哈希,每次加密都不同)
|
||||
password_hash($combined, PASSWORD_BCRYPT, ['cost' => 12])
|
||||
│
|
||||
▼
|
||||
存储:
|
||||
password 字段 → $2y$12$...(60字符 bcrypt 哈希)
|
||||
salt 字段 → xK7pMn3qRt(10字符外部盐)
|
||||
```
|
||||
|
||||
### 为什么双重加盐?
|
||||
|
||||
```text
|
||||
单层 bcrypt(常规方案):
|
||||
password_hash("123456", PASSWORD_BCRYPT)
|
||||
→ 相同明文每次生成不同哈希(bcrypt 内部自动加盐)
|
||||
→ 已经足够安全
|
||||
|
||||
双重加盐(本项目方案):
|
||||
password_hash("123456" + "xK7pMn3qRt", PASSWORD_BCRYPT)
|
||||
→ 外部盐单独存储,增加一层隔离
|
||||
→ 即使 bcrypt 哈希泄露,攻击者还需获取外部盐
|
||||
→ 不同用户即使使用相同密码,因外部盐不同,哈希也完全不同
|
||||
```
|
||||
|
||||
### bcrypt 工作因子
|
||||
|
||||
| 工作因子 (cost) | 计算耗时 | 安全性 | 适用场景 |
|
||||
|----------------|---------|--------|---------|
|
||||
| 10 | ~60ms | 中 | 低性能服务器 |
|
||||
| **12** | **~250ms** | **高** | **本项目默认值** |
|
||||
| 14 | ~1s | 很高 | 高安全需求 |
|
||||
| 16 | ~4s | 极高 | 不推荐(影响用户体验) |
|
||||
|
||||
:::tip 工作因子每 +1,计算耗时翻倍
|
||||
本项目默认 cost=12,约 250ms,在安全与性能之间取得平衡。可在 `PasswordService::BCRYPT_ROUNDS` 常量中调整。
|
||||
:::
|
||||
|
||||
## 核心代码
|
||||
|
||||
```php
|
||||
// app/service/PasswordService.php
|
||||
|
||||
class PasswordService
|
||||
{
|
||||
const BCRYPT_ROUNDS = 12; // 工作因子(值越大越安全,12 约 250ms)
|
||||
const SALT_LENGTH = 10; // 外部盐值长度
|
||||
|
||||
/**
|
||||
* 生成随机盐值
|
||||
*
|
||||
* 用 random_bytes 生成安全随机字节,再 base64 编码并截取指定长度,
|
||||
* 去除 base64 的填充字符 '=',保证盐值可直接入库。
|
||||
*/
|
||||
public static function generateSalt(int $length = self::SALT_LENGTH): string
|
||||
{
|
||||
$bytes = random_bytes((int)ceil($length * 3 / 4));
|
||||
return substr(rtrim(base64_encode($bytes), '='), 0, $length);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密密码(外部 salt 双重加盐)
|
||||
*
|
||||
* 处理流程:
|
||||
* 1. 未传 salt 时自动生成;
|
||||
* 2. 将明文密码与外部盐拼接;
|
||||
* 3. 用 bcrypt 加密(内部会再生成一层盐)。
|
||||
*
|
||||
* @return array ['password' => 'bcrypt哈希', 'salt' => '盐值']
|
||||
*/
|
||||
public static function encrypt(string $password, ?string $salt = null): array
|
||||
{
|
||||
if (empty($salt)) {
|
||||
$salt = self::generateSalt();
|
||||
}
|
||||
|
||||
$combined = $password . $salt;
|
||||
|
||||
$hashed = password_hash($combined, PASSWORD_BCRYPT, [
|
||||
'cost' => self::BCRYPT_ROUNDS,
|
||||
]);
|
||||
|
||||
return [
|
||||
'password' => $hashed,
|
||||
'salt' => $salt,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密码
|
||||
*
|
||||
* 用数据库中存储的 salt 对明文做同样拼接,
|
||||
* 再用 password_verify 与存储的哈希比对。
|
||||
*/
|
||||
public static function verify(string $password, string $hashed, string $salt): bool
|
||||
{
|
||||
$combined = $password . $salt;
|
||||
return password_verify($combined, $hashed);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查密码是否需要重新加密(迁移旧密码)
|
||||
*
|
||||
* 当 BCRYPT_ROUNDS 调整后,可用此方法识别旧哈希,
|
||||
* 在用户下次登录时透明地重新加密。
|
||||
*/
|
||||
public static function needsRehash(string $hashed): bool
|
||||
{
|
||||
return password_needs_rehash($hashed, PASSWORD_BCRYPT, [
|
||||
'cost' => self::BCRYPT_ROUNDS,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认密码明文
|
||||
*
|
||||
* 从系统参数 DEFAULT_PASSWORD 获取,未配置时回退为 123456。
|
||||
* 注意:本方法返回明文,切勿写入日志或返回给前端。
|
||||
*/
|
||||
public static function getDefaultPasswordPlain(): string
|
||||
{
|
||||
return ParamService::getDefaultPassword();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 方法清单
|
||||
|
||||
| 方法 | 说明 | 返回值 |
|
||||
|------|------|--------|
|
||||
| `encrypt($password, $salt?)` | 加密密码 | `['password' => '哈希', 'salt' => '盐值']` |
|
||||
| `verify($password, $hashed, $salt)` | 验证密码 | `bool` |
|
||||
| `generateSalt($length?)` | 生成随机盐值 | `string`(默认 10 字符) |
|
||||
| `needsRehash($hashed)` | 检查是否需要重新加密 | `bool` |
|
||||
| `defaultPassword()` | 获取默认密码的加密结果 | `['password' => '哈希', 'salt' => '盐值']` |
|
||||
| `getDefaultPasswordPlain()` | 获取默认密码明文 | `string`(默认 '123456') |
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 1. 新增用户
|
||||
|
||||
```php
|
||||
// UserLogic::beforeAdd()
|
||||
protected function beforeAdd(array $data): array
|
||||
{
|
||||
$password = $data['password'] ?? PasswordService::getDefaultPasswordPlain();
|
||||
$encrypted = PasswordService::encrypt($password);
|
||||
$data['password'] = $encrypted['password'];
|
||||
$data['salt'] = $encrypted['salt'];
|
||||
return $data;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 登录验证
|
||||
|
||||
```php
|
||||
// JwtService::login()
|
||||
if (!PasswordService::verify($password, $user->password, $user->salt)) {
|
||||
throw new \Exception('用户名或密码错误');
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 重置密码
|
||||
|
||||
```php
|
||||
// UserLogic::resetPassword()
|
||||
public function resetPassword(int $id, string $newPassword = ''): bool
|
||||
{
|
||||
$user = User::find($id);
|
||||
if (!$user) {
|
||||
throw new \Exception('用户不存在');
|
||||
}
|
||||
|
||||
if (empty($newPassword)) {
|
||||
$newPassword = PasswordService::getDefaultPasswordPlain();
|
||||
}
|
||||
|
||||
$encrypted = PasswordService::encrypt($newPassword);
|
||||
$user->password = $encrypted['password'];
|
||||
$user->salt = $encrypted['salt'];
|
||||
return $user->save() !== false;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 租户账号创建
|
||||
|
||||
```php
|
||||
// TenantLogic::createAccount()
|
||||
$password = $data['password'] ?? PasswordService::getDefaultPasswordPlain();
|
||||
$encrypted = PasswordService::encrypt($password);
|
||||
$data['password'] = $encrypted['password'];
|
||||
$data['salt'] = $encrypted['salt'];
|
||||
```
|
||||
|
||||
### 5. 工作因子迁移(透明升级)
|
||||
|
||||
```php
|
||||
// 登录成功后检查是否需要重新加密
|
||||
if (PasswordService::needsRehash($user->password)) {
|
||||
$encrypted = PasswordService::encrypt($inputPassword);
|
||||
$user->password = $encrypted['password'];
|
||||
$user->salt = $encrypted['salt'];
|
||||
$user->save();
|
||||
}
|
||||
```
|
||||
|
||||
## 便捷函数
|
||||
|
||||
```php
|
||||
// app/common.php 中的全局函数,转发到 PasswordService
|
||||
|
||||
// 加密
|
||||
$result = encrypt_password('123456');
|
||||
// → ['password' => '$2y$12$...', 'salt' => 'xK7pMn3qRt']
|
||||
|
||||
// 验证
|
||||
$isValid = verify_password('123456', $hash, $salt);
|
||||
// → true / false
|
||||
```
|
||||
|
||||
## 数据库存储
|
||||
|
||||
```sql
|
||||
-- think_user 表相关字段
|
||||
password VARCHAR(255) -- bcrypt 哈希值(60字符)
|
||||
salt VARCHAR(50) -- 外部盐值(10字符)
|
||||
|
||||
-- 示例数据
|
||||
password: $2y$12$LJ3m9R8s7Kq2vN5pX4wYzeK8vN5pX4wYzeK8vN5pX4wYzeK8vN5pX4
|
||||
salt: xK7pMn3qRt
|
||||
```
|
||||
|
||||
:::warning 安全提示
|
||||
- `password` 和 `salt` 字段在 BaseModel 序列化时自动排除,不会出现在 API 响应中
|
||||
- bcrypt 工作因子为 12(约 250ms),暴力破解成本极高
|
||||
- 即使数据库泄露,也无法还原明文密码
|
||||
- `getDefaultPasswordPlain()` 返回明文,切勿写入日志或返回给前端
|
||||
:::
|
||||
|
||||
## 安全特性
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 双重加盐 | 外部盐 + bcrypt 内部盐,两层保护 |
|
||||
| 每用户独立盐 | 每个用户的 salt 不同,相同密码产生不同哈希 |
|
||||
| bcrypt 单向哈希 | 不可逆,无法从哈希还原明文 |
|
||||
| 工作因子可调 | `BCRYPT_ROUNDS` 常量控制计算强度 |
|
||||
| 自动迁移 | `needsRehash()` 支持透明升级工作因子 |
|
||||
| 字段序列化排除 | password/salt 不出现在 API 响应中 |
|
||||
| 安全随机数 | `random_bytes()` 生成盐值,非伪随机 |
|
||||
|
||||
## 性能考量
|
||||
|
||||
```text
|
||||
bcrypt cost=12 的性能特征:
|
||||
单次加密:~250ms
|
||||
单次验证:~250ms
|
||||
并发能力:4 次/秒/CPU 核心
|
||||
|
||||
优化建议:
|
||||
1. 登录验证是唯一需要等待的场景,用户可接受
|
||||
2. 批量创建用户时,加密耗时 = 用户数 × 250ms
|
||||
3. 如需更高性能,可将 cost 降至 10(~60ms)
|
||||
4. 不建议低于 10(安全性不足)
|
||||
```
|
||||
|
||||
## 与旧密码方案的兼容
|
||||
|
||||
如果系统从旧的密码方案(如 MD5)迁移到 bcrypt:
|
||||
|
||||
```php
|
||||
// 在登录流程中检测旧密码格式
|
||||
public static function verify(string $password, string $hashed, string $salt): bool
|
||||
{
|
||||
// 检测是否为旧的 MD5 格式(32字符十六进制)
|
||||
if (strlen($hashed) === 32 && ctype_xdigit($hashed)) {
|
||||
// 旧方案验证
|
||||
$isValid = md5($password . $salt) === $hashed;
|
||||
if ($isValid) {
|
||||
// 验证通过后自动迁移到 bcrypt
|
||||
$newEncrypted = self::encrypt($password);
|
||||
// 由调用方保存新密码到数据库
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 正常 bcrypt 验证
|
||||
$combined = $password . $salt;
|
||||
return password_verify($combined, $hashed);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,394 @@
|
||||
# 5.4 权限管理(RBAC)
|
||||
|
||||
:::tip 概述
|
||||
系统采用 RBAC(基于角色的访问控制)权限模型,通过"用户 → 角色 → 菜单/权限"三层关系实现细粒度的权限控制。权限控制分为两个维度:**功能权限**(能做什么操作)和**数据权限**(能看到哪些数据)。
|
||||
:::
|
||||
|
||||
## 权限模型
|
||||
|
||||
### ER 关系图
|
||||
|
||||
```text
|
||||
┌─────────┐ ┌────────────┐ ┌─────────┐ ┌────────────┐ ┌─────────┐
|
||||
│ User │───►│ UserRole │───►│ Role │───►│ RoleMenu │───►│ Menu │
|
||||
│ │ │ │ │ │ │ │ │ │
|
||||
│ id │ │ user_id │ │ id │ │ role_id │ │ id │
|
||||
│ username│ │ role_id │ │tenant_id│ │ menu_id │ │ perm │
|
||||
│ realname│ └────────────┘ │ name │ └────────────┘ │ type │
|
||||
│ dept_id │ │ code │ │ parent │
|
||||
│tenant_id│ │data_scope│ │ sort │
|
||||
└─────────┘ └─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
### 关联关系说明
|
||||
|
||||
| 关系 | 说明 | 中间表 |
|
||||
|------|------|--------|
|
||||
| User ↔ Role | 多对多:一个用户可有多个角色,一个角色可分配给多个用户 | `think_user_role` |
|
||||
| Role ↔ Menu | 多对多:一个角色可有多个菜单/权限,一个菜单可分配给多个角色 | `think_role_menu` |
|
||||
| User → Dept | 多对一:一个用户属于一个部门 | `dept_id` 外键 |
|
||||
| User → Tenant | 多对一:一个用户属于一个租户 | `tenant_id` 外键 |
|
||||
|
||||
### 核心表结构
|
||||
|
||||
**think_role(角色表):**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INT | 主键 |
|
||||
| `tenant_id` | INT | 租户ID:0=平台级 |
|
||||
| `name` | VARCHAR(50) | 角色名称 |
|
||||
| `code` | VARCHAR(50) | 角色编码 |
|
||||
| `data_scope` | TINYINT | 数据权限范围:1=全部 2=本部门 3=仅本人 |
|
||||
| `status` | TINYINT | 状态 |
|
||||
| `remark` | VARCHAR(500) | 备注 |
|
||||
| `sort` | INT | 排序 |
|
||||
|
||||
**think_menu(菜单/权限表):**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INT | 主键 |
|
||||
| `parent_id` | INT | 父级 ID(0=顶级) |
|
||||
| `name` | VARCHAR(50) | 菜单名称 |
|
||||
| `permission` | VARCHAR(100) | 权限标识(如 `sys:user:add`) |
|
||||
| `type` | TINYINT | 类型:0=目录 1=菜单 2=按钮 |
|
||||
| `path` | VARCHAR(200) | 路由路径 |
|
||||
| `icon` | VARCHAR(100) | 图标 |
|
||||
| `sort` | INT | 排序 |
|
||||
| `status` | TINYINT | 状态 |
|
||||
|
||||
## 权限码格式
|
||||
|
||||
```text
|
||||
sys:{模块名}:{操作}
|
||||
```
|
||||
|
||||
### 权限码命名规范
|
||||
|
||||
| 操作 | 权限码 | 说明 |
|
||||
|------|--------|------|
|
||||
| 分页查询 | `sys:{module}:page` | 列表页 |
|
||||
| 全量列表 | `sys:{module}:list` | 下拉框数据源 |
|
||||
| 详情 | `sys:{module}:detail` | 查看详情 |
|
||||
| 新增 | `sys:{module}:add` | 添加记录 |
|
||||
| 修改 | `sys:{module}:update` | 修改记录 |
|
||||
| 删除 | `sys:{module}:delete` | 删除记录 |
|
||||
| 批量删除 | `sys:{module}:batchDelete` | 批量删除 |
|
||||
| 导入 | `sys:{module}:import` | Excel 导入 |
|
||||
| 导出 | `sys:{module}:export` | Excel 导出 |
|
||||
| 重置密码 | `sys:{module}:resetPwd` | 重置密码 |
|
||||
|
||||
### 权限码示例
|
||||
|
||||
```text
|
||||
用户管理:
|
||||
sys:user:page 用户分页
|
||||
sys:user:detail 用户详情
|
||||
sys:user:add 添加用户
|
||||
sys:user:update 修改用户
|
||||
sys:user:delete 删除用户
|
||||
sys:user:batchDelete 批量删除用户
|
||||
sys:user:import 导入用户
|
||||
sys:user:export 导出用户
|
||||
sys:user:resetPwd 重置密码
|
||||
|
||||
角色管理:
|
||||
sys:role:page 角色分页
|
||||
sys:role:add 添加角色
|
||||
sys:role:update 修改角色
|
||||
sys:role:delete 删除角色
|
||||
|
||||
文章管理:
|
||||
sys:article:page 文章分页
|
||||
sys:article:add 添加文章
|
||||
sys:article:update 修改文章
|
||||
sys:article:delete 删除文章
|
||||
```
|
||||
|
||||
## 菜单类型
|
||||
|
||||
| type | 说明 | 前端行为 | 权限校验 |
|
||||
|------|------|----------|----------|
|
||||
| 0 | 目录 | 渲染为侧边栏分组(如"系统管理") | 不参与权限校验 |
|
||||
| 1 | 菜单 | 渲染为可点击的菜单项(如"用户管理") | 参与权限校验(AuthMiddleware 查询 `type=1`) |
|
||||
| 2 | 按钮 | 不渲染,用于定义子级权限码 | 权限码挂在 type=1 的父菜单下 |
|
||||
|
||||
### 菜单树示例
|
||||
|
||||
```text
|
||||
系统管理 (type=0, 目录)
|
||||
├── 用户管理 (type=1, 菜单, permission=sys:user:page)
|
||||
│ ├── 添加用户 (type=2, 按钮, permission=sys:user:add)
|
||||
│ ├── 修改用户 (type=2, 按钮, permission=sys:user:update)
|
||||
│ ├── 删除用户 (type=2, 按钮, permission=sys:user:delete)
|
||||
│ └── 重置密码 (type=2, 按钮, permission=sys:user:resetPwd)
|
||||
├── 角色管理 (type=1, 菜单, permission=sys:role:page)
|
||||
│ ├── 添加角色 (type=2, 按钮, permission=sys:role:add)
|
||||
│ └── 修改角色 (type=2, 按钮, permission=sys:role:update)
|
||||
└── 菜单管理 (type=1, 菜单, permission=sys:menu:page)
|
||||
|
||||
内容管理 (type=0, 目录)
|
||||
├── 文章管理 (type=1, 菜单, permission=sys:article:page)
|
||||
│ ├── 添加文章 (type=2, 按钮, permission=sys:article:add)
|
||||
│ └── 删除文章 (type=2, 按钮, permission=sys:article:delete)
|
||||
└── 分类管理 (type=1, 菜单, permission=sys:category:page)
|
||||
```
|
||||
|
||||
## 权限校验流程
|
||||
|
||||
### 完整流程图
|
||||
|
||||
```text
|
||||
请求进入 AuthMiddleware
|
||||
│
|
||||
▼
|
||||
1. 从 JWT 获取 uid
|
||||
│
|
||||
▼
|
||||
2. uid === 1(超级管理员)?
|
||||
│ 是 → 直接放行
|
||||
▼
|
||||
3. 读取 #[Permission] 注解
|
||||
│ 无注解 → 放行(该接口不需要权限)
|
||||
▼
|
||||
4. 获取用户角色列表
|
||||
UserRole::where('user_id', $uid)->column('role_id')
|
||||
│ 无角色 → return false → 403
|
||||
▼
|
||||
5. 获取角色关联的权限节点
|
||||
RoleMenu → Menu::whereIn('role_id', $roleIds)
|
||||
.where('type', 1) // 只查权限节点
|
||||
.where('is_delete', 0)
|
||||
.column('permission')
|
||||
▼
|
||||
6. 判断权限码是否在列表中
|
||||
in_array($permissionCode, $permissions)
|
||||
│ 不包含 → return false → 403
|
||||
▼
|
||||
7. 放行
|
||||
```
|
||||
|
||||
### 校验代码
|
||||
|
||||
```php
|
||||
// app/middleware/AuthMiddleware.php
|
||||
|
||||
protected function checkPermission(int $uid, string $permissionCode): bool
|
||||
{
|
||||
// 超级管理员跳过校验
|
||||
if ($uid === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 获取用户角色
|
||||
$roleIds = UserRole::where('user_id', $uid)->column('role_id');
|
||||
if (empty($roleIds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取角色关联的权限节点
|
||||
$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');
|
||||
|
||||
return in_array($permissionCode, $permissions);
|
||||
}
|
||||
```
|
||||
|
||||
### 错误响应
|
||||
|
||||
| 场景 | HTTP 状态 | code | msg |
|
||||
|------|----------|------|-----|
|
||||
| 无权限 | 200 | 403 | 无访问权限 |
|
||||
| 无角色 | 200 | 403 | 无访问权限 |
|
||||
|
||||
## 在 Controller 中使用
|
||||
|
||||
### 声明权限注解
|
||||
|
||||
```php
|
||||
use app\attribute\Log;
|
||||
use app\attribute\Permission;
|
||||
|
||||
class UserController extends BaseController
|
||||
{
|
||||
// ── 查询类 ──
|
||||
#[Permission('sys:user:page', '用户分页')]
|
||||
public function page(): Json { ... }
|
||||
|
||||
#[Permission('sys:user:detail', '用户详情')]
|
||||
public function detail(int $id): Json { ... }
|
||||
|
||||
// ── 写操作类 ──
|
||||
#[Log('用户管理-新增记录', Log::TYPE_ADD, '新增用户:{username}')]
|
||||
#[Permission('sys:user:add', '添加用户')]
|
||||
public function add(): Json { ... }
|
||||
|
||||
#[Log('用户管理-修改记录', Log::TYPE_UPDATE, '修改用户ID:{id}')]
|
||||
#[Permission('sys:user:update', '修改用户')]
|
||||
public function update(): Json { ... }
|
||||
|
||||
#[Log('用户管理-删除记录', Log::TYPE_DELETE, '删除用户ID:{id}')]
|
||||
#[Permission('sys:user:delete', '删除用户')]
|
||||
public function delete(int $id): Json { ... }
|
||||
|
||||
// ── 特殊操作类 ──
|
||||
#[Log('用户管理-重置密码', Log::TYPE_RESET, '重置用户ID:{id}的密码')]
|
||||
#[Permission('sys:user:resetPwd', '重置密码')]
|
||||
public function resetPwd(): Json { ... }
|
||||
|
||||
// ── 不需要权限的接口 ──
|
||||
// 不标注 #[Permission] 即可,所有已登录用户都可访问
|
||||
public function getOptions(): Json { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 注解组合规则
|
||||
|
||||
```text
|
||||
#[Log] + #[Permission] 组合使用:
|
||||
- Log:记录操作日志(无论权限校验是否通过)
|
||||
- Permission:校验权限(不通过返回 403)
|
||||
|
||||
执行顺序:
|
||||
AuthMiddleware 先校验权限 → 通过后 LogMiddleware 记录日志
|
||||
|
||||
权限校验失败时:
|
||||
- 返回 403,不进入 Controller
|
||||
- LogMiddleware 可选择是否记录(取决于配置)
|
||||
```
|
||||
|
||||
## 数据权限
|
||||
|
||||
### 三种权限范围
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ data_scope = 1 → 全部数据 │
|
||||
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
|
||||
│ │ A │ │ B │ │ C │ │ D │ │ E │ ← 所有部门所有人的数据 │
|
||||
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ data_scope = 2 → 本部门数据 │
|
||||
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
|
||||
│ │ A │ │ B │ │███│ │ D │ │ E │ ← 只看到本部门数据 │
|
||||
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ data_scope = 3 → 仅本人数据 │
|
||||
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
|
||||
│ │ A │ │ B │ │███│ │ D │ │ E │ ← 只看到自己创建的数据 │
|
||||
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 配置方式
|
||||
|
||||
在 Logic 中配置数据权限字段即可启用:
|
||||
|
||||
```php
|
||||
class ArticleLogic extends BaseTenantLogic
|
||||
{
|
||||
/**
|
||||
* 数据权限:归属字段名(按创建人过滤,data_scope=3 时生效)
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $dataScopeUserField = 'create_user';
|
||||
|
||||
/**
|
||||
* 数据权限:部门关联字段名(按部门过滤,data_scope=2 时生效)
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $dataScopeDeptField = 'dept_id';
|
||||
}
|
||||
```
|
||||
|
||||
### 工作原理
|
||||
|
||||
```text
|
||||
查询时 applyDataScope():
|
||||
1. 获取当前用户角色 ID
|
||||
2. 查询角色的 data_scope
|
||||
3. scope=1 → 不过滤,返回全部数据
|
||||
4. scope=2 → WHERE dept_id = 当前用户部门ID
|
||||
5. scope=3 → WHERE create_user = 当前用户名
|
||||
```
|
||||
|
||||
:::warning 无角色用户
|
||||
无角色时默认按"仅本人"(scope=3)处理。
|
||||
:::
|
||||
|
||||
## 权限管理接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/role/page` | GET | 角色分页列表 |
|
||||
| `/role/add` | POST | 添加角色(含菜单权限) |
|
||||
| `/role/update` | PUT | 修改角色(含菜单权限) |
|
||||
| `/role/delete/{id}` | DELETE | 删除角色 |
|
||||
| `/role/menus/{id}` | GET | 获取角色的菜单 ID 列表 |
|
||||
| `/menu/treeList` | GET | 菜单树形列表(用于权限分配) |
|
||||
|
||||
### 角色权限分配流程
|
||||
|
||||
```text
|
||||
1. 前端调用 GET /menu/treeList 获取完整菜单树
|
||||
2. 前端调用 GET /role/menus/{roleId} 获取该角色已有的菜单 ID
|
||||
3. 前端渲染树形 checkbox,回显已选中项
|
||||
4. 用户勾选/取消菜单权限
|
||||
5. 前端调用 POST /role/add 或 PUT /role/update 提交
|
||||
{ name, code, dataScope, menuIds: [1,2,3,...] }
|
||||
6. 后端 RoleLogic::afterAdd/afterUpdate 清空并重新写入 role_menu 关联表
|
||||
```
|
||||
|
||||
## 前端权限控制
|
||||
|
||||
### 按钮级权限指令
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- 单权限:拥有 sys:user:add 才显示 -->
|
||||
<el-button v-perm="'sys:user:add'" type="primary">新增</el-button>
|
||||
|
||||
<!-- 多权限:拥有任一权限即显示 -->
|
||||
<el-button v-perms="['sys:user:add', 'sys:user:update']">操作</el-button>
|
||||
|
||||
<!-- 无权限的按钮直接从 DOM 移除,而非仅隐藏 -->
|
||||
</template>
|
||||
```
|
||||
|
||||
### 超级管理员
|
||||
|
||||
用户权限列表包含 `*:*:*` 时,表示超级管理员,`v-perm`/`v-perms` 指令始终放行。
|
||||
|
||||
### 菜单动态渲染
|
||||
|
||||
```text
|
||||
后端 GET /index/getMenus
|
||||
│ 返回当前用户的菜单树(已过滤无权限的菜单)
|
||||
▼
|
||||
前端 asyncRouteStore
|
||||
│ 将菜单树转换为 Vue Router 路由
|
||||
│ router.addRoute() 动态注入
|
||||
▼
|
||||
侧边栏组件
|
||||
│ 遍历菜单树渲染菜单项
|
||||
▼
|
||||
用户只能看到有权限的菜单
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
| 实践 | 说明 |
|
||||
|------|------|
|
||||
| 权限码命名统一 | 使用 `sys:{module}:{action}` 格式 |
|
||||
| 最小权限原则 | 只分配必要的权限,不给多余权限 |
|
||||
| 角色职责单一 | 每个角色对应明确的职责范围 |
|
||||
| 定期审计权限 | 定期检查角色权限分配是否合理 |
|
||||
| 超级管理员仅一个 | uid=1 仅用于系统初始化,日常使用普通管理员账号 |
|
||||
| 数据权限配合功能权限 | 功能权限控制"能做什么",数据权限控制"能看到什么" |
|
||||
@@ -0,0 +1,245 @@
|
||||
# 5.8 多租户隔离
|
||||
|
||||
:::tip 概述
|
||||
采用共享数据库、共享表、`tenant_id` 字段隔离的方案。通过 `BaseTenantLogic` 基类实现声明式租户隔离,`TenantMiddleware` 注入租户上下文,`BaseLogic::processTenantId()` 自动填充租户ID。
|
||||
:::
|
||||
|
||||
## 设计原则
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ tenant_id 语义 │
|
||||
│ │
|
||||
│ tenant_id = 0 → 超级管理员(平台级,不归属任何租户) │
|
||||
│ tenant_id = 1 → 默认租户(平台维护数据的归属租户) │
|
||||
│ tenant_id >= 2 → 业务租户(按注册顺序自增) │
|
||||
│ │
|
||||
│ ⚠️ 超管创建租户隔离数据时,自动代入默认租户(tenant_id=1) │
|
||||
│ 而非写入 tenant_id=0 │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
:::warning 核心约定
|
||||
`tenant_id = 0` 是**权限标记**(标识超级管理员),不是**数据归属标记**。超管创建的租户隔离数据归属默认租户(`tenant_id = 1`),而非归属"超管"(`tenant_id = 0`)。
|
||||
:::
|
||||
|
||||
## 表分类
|
||||
|
||||
系统中的表分为两类,通过 Logic 基类选择来决定:
|
||||
|
||||
| 类型 | 基类 | 说明 | 举例 |
|
||||
|------|------|------|------|
|
||||
| 系统共享表 | `BaseLogic` | 不做租户隔离,所有角色均可访问 | Menu、Dict、Config、Param、Tenant、FileTemplate |
|
||||
| 租户隔离表 | `BaseTenantLogic` | 按 `tenant_id` 自动过滤和填充 | User、Role、Article、Category、Dept、Level、Position、Link、Notice |
|
||||
|
||||
```php
|
||||
// 系统共享表:继承 BaseLogic
|
||||
class MenuLogic extends BaseLogic { ... }
|
||||
class DictLogic extends BaseLogic { ... }
|
||||
|
||||
// 租户隔离表:继承 BaseTenantLogic
|
||||
class UserLogic extends BaseTenantLogic { ... }
|
||||
class RoleLogic extends BaseTenantLogic { ... }
|
||||
class ArticleLogic extends BaseTenantLogic { ... }
|
||||
```
|
||||
|
||||
:::danger 继承选错的后果
|
||||
- 租户隔离表继承了 `BaseLogic` → 数据泄漏,所有租户互相可见
|
||||
- 系统共享表继承了 `BaseTenantLogic` → 菜单/字典等数据被隔离,租户用户看不到
|
||||
:::
|
||||
|
||||
## 隔离机制总览
|
||||
|
||||
```text
|
||||
请求进入
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ AuthMiddleware │ 解析 JWT → 注入 request->userInfo
|
||||
└──────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ TenantMiddleware │ 查询用户 tenant_id → 注入 request->tenantId
|
||||
│ │ 校验租户状态(存在、启用、未过期)
|
||||
└──────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ BaseLogic │
|
||||
│ ├─ applyTenantScope │ 查询时:tenantId=0 不过滤,tenantId>0 加 WHERE
|
||||
│ └─ processTenantId │ 新增时:tenantId=0 代入默认租户,tenantId>0 自动填充
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
## 租户中间件
|
||||
|
||||
```php
|
||||
// app/middleware/TenantMiddleware.php
|
||||
|
||||
public function handle(Request $request, \Closure $next): Response
|
||||
{
|
||||
// 从 JWT 注入的 userInfo 取用户 ID
|
||||
$userInfo = $request->userInfo ?? null;
|
||||
$userId = $userInfo->uid ?? 0;
|
||||
|
||||
$user = User::find($userId);
|
||||
$tenantId = $user->tenant_id ?? 0;
|
||||
|
||||
// 注入租户上下文
|
||||
$request->tenantId = $tenantId;
|
||||
$request->isSuperAdmin = ($tenantId == 0);
|
||||
|
||||
// 非超级管理员校验租户状态
|
||||
if ($tenantId > 0) {
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if (!$tenant) {
|
||||
return json(['code' => 403, 'msg' => '租户不存在']);
|
||||
}
|
||||
if ($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;
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
```
|
||||
|
||||
| 注入字段 | 类型 | 说明 |
|
||||
|----------|------|------|
|
||||
| `request->tenantId` | `int` | 当前用户所属租户ID,超管为 0 |
|
||||
| `request->isSuperAdmin` | `bool` | 是否为超级管理员(tenantId == 0) |
|
||||
| `request->tenantInfo` | `Tenant\|null` | 租户模型实例,超管为 null |
|
||||
|
||||
## 查询隔离(applyTenantScope)
|
||||
|
||||
```php
|
||||
// app/BaseLogic.php
|
||||
|
||||
protected function applyTenantScope($model)
|
||||
{
|
||||
if (empty($this->tenantScopeField)) {
|
||||
return $model;
|
||||
}
|
||||
|
||||
$tenantId = request()->tenantId ?? 0;
|
||||
|
||||
// 超级管理员不过滤(看到所有租户数据)
|
||||
if ($tenantId == 0) {
|
||||
return $model;
|
||||
}
|
||||
|
||||
// 租户用户:只看到自己租户的数据
|
||||
return $model->where($this->tenantScopeField, $tenantId);
|
||||
}
|
||||
```
|
||||
|
||||
各角色查询行为:
|
||||
|
||||
| 角色 | tenantId | 查询条件 | 可见数据 |
|
||||
|------|----------|----------|----------|
|
||||
| 超级管理员 | 0 | 无 tenant_id 过滤 | 所有租户数据 |
|
||||
| 默认租户用户 | 1 | `WHERE tenant_id = 1` | 默认租户数据(含超管创建的数据) |
|
||||
| 业务租户用户 | 2 | `WHERE tenant_id = 2` | 仅本租户数据 |
|
||||
|
||||
## 新增填充(processTenantId)
|
||||
|
||||
```php
|
||||
// app/BaseLogic.php
|
||||
|
||||
protected function processTenantId(array $data): array
|
||||
{
|
||||
if (empty($this->tenantScopeField)) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$tenantId = request()->tenantId ?? 0;
|
||||
|
||||
// 超级管理员代入默认租户
|
||||
if ($tenantId == 0) {
|
||||
$defaultTenantId = config('tenant.default_tenant_id', 1);
|
||||
if (!isset($data[$this->tenantScopeField])) {
|
||||
$data[$this->tenantScopeField] = $defaultTenantId;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
// 租户用户自动填充 tenant_id
|
||||
if ($tenantId > 0 && !isset($data[$this->tenantScopeField])) {
|
||||
$data[$this->tenantScopeField] = $tenantId;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
```
|
||||
|
||||
各角色新增行为:
|
||||
|
||||
| 角色 | tenantId | 写入的 tenant_id | 说明 |
|
||||
|------|----------|-----------------|------|
|
||||
| 超级管理员 | 0 | 1(默认租户) | 自动代入,可通过配置修改 |
|
||||
| 默认租户用户 | 1 | 1 | 自动填充 |
|
||||
| 业务租户用户 | 2 | 2 | 自动填充 |
|
||||
|
||||
:::tip 为什么不写入 tenant_id = 0?
|
||||
如果超管创建的数据写入 `tenant_id = 0`:
|
||||
- 租户用户查询条件是 `WHERE tenant_id = N`,永远看不到这些数据
|
||||
- 语义混乱:`tenant_id = 0` 是权限标记,不应作为数据归属
|
||||
- 数据迁移困难:无法区分"平台数据"和"脏数据"
|
||||
:::
|
||||
|
||||
## 配置
|
||||
|
||||
```php
|
||||
// config/tenant.php
|
||||
|
||||
return [
|
||||
// 默认租户ID,超管操作租户隔离表时自动代入
|
||||
'default_tenant_id' => 1,
|
||||
];
|
||||
```
|
||||
|
||||
## 超级管理员行为汇总
|
||||
|
||||
| 操作 | 行为 |
|
||||
|------|------|
|
||||
| 查询租户隔离数据 | 不过滤,看到所有租户数据 |
|
||||
| 新增租户隔离数据 | 自动填充 `tenant_id = 1`(默认租户) |
|
||||
| 查询系统共享数据 | 正常访问(共享表本身无租户过滤) |
|
||||
| 权限校验 | 跳过(uid = 1 直接放行) |
|
||||
| 租户状态校验 | 跳过(tenantId = 0 不校验) |
|
||||
|
||||
## 与数据权限的关系
|
||||
|
||||
多租户隔离和数据权限是两个独立维度,可同时生效:
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 第一层:租户隔离(tenant_id) │
|
||||
│ 不同租户数据完全隔离 │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────┐ │
|
||||
│ │ 第二层:数据权限(data_scope) │ │
|
||||
│ │ 同一租户内按部门/创建人进一步过滤 │ │
|
||||
│ │ │ │
|
||||
│ │ data_scope=1 → 全部数据 │ │
|
||||
│ │ data_scope=2 → 本部门数据 │ │
|
||||
│ │ data_scope=3 → 仅本人数据 │ │
|
||||
│ └───────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
租户隔离优先级更高。详见 [数据权限](/zh/core/data-scope.html)。
|
||||
|
||||
## 开发 Checklist
|
||||
|
||||
新增业务模块时,按以下清单判断租户隔离:
|
||||
|
||||
- [ ] 该模块数据是否需要按租户隔离?→ 是:继承 `BaseTenantLogic`,否:继承 `BaseLogic`
|
||||
- [ ] 数据库表是否需要 `tenant_id` 字段?→ 租户隔离表必须有
|
||||
- [ ] 是否需要数据权限(部门/创建人过滤)?→ 配置 `dataScopeDeptField` 或 `dataScopeUserField`
|
||||
- [ ] 数据库脚本中是否已正确设置 `tenant_id`?→ 提供的数据库脚本已包含完整结构和数据
|
||||
Reference in New Issue
Block a user