签入版本

This commit is contained in:
2026-10-08 17:50:29 +08:00
parent 809750b47e
commit e213a9b31d
444 changed files with 39366 additions and 37533 deletions
+147 -175
View File
@@ -1,16 +1,16 @@
# 5.18 注解系统总览
# 5.17 注解系统总览
:::tip 概述
项目使用 PHP 8 原生注解(Attribute)实现声明式配置,目前有三个注解类,由 AttributeService 通过反射读取。注解标注在控制器方法或类上,中间件在请求处理过程中读取并执行对应逻辑(日志记录、权限校验、演示放行)。
项目使用 PHP 8 原生注解(Attribute)实现声明式配置,目前有三个注解类(`app/Attributes/`)。注解标注在控制器方法上,中间件在请求处理过程中通过**反射(ReflectionMethod)读取**并执行对应逻辑(日志记录、权限校验、演示放行)——业务代码零侵入。
:::
## 注解清单
| 注解 | 作用域 | 读取方 | 用途 | 必填参数 |
|------|--------|--------|------|---------|
| `#[Log]` | 方法 | LogMiddleware | 操作日志记录 | title |
| `#[Permission]` | 方法/类 | AuthMiddleware | 权限校验 | code |
| `#[DemoAllow]` | 方法 | DemoMiddleware | 演示环境放行 | 无 |
| 注解 | 文件 | 作用域 | 读取方 | 用途 | 必填参数 |
|------|------|--------|--------|------|---------|
| `#[OperationLog]` | `OperationLog.php`(275 行) | 方法 | `LogOperation` 中间件 | 操作日志记录 | title |
| `#[Permission]` | `Permission.php` | 方法 | `Authenticate` 中间件 | 权限校验 | code |
| `#[DemoAllow]` | `DemoAllow.php` | 方法 | `DemoMiddleware` | 演示环境放行 | 无 |
## 注解执行顺序
@@ -18,100 +18,103 @@
请求进入
│
▼
CorsMiddleware(全局)
ProxyMiddleware / Cors / ForceJsonResponse(全局 api 组)
│
▼
AuthMiddleware
│ 读取 #[Permission] 注解
│ 校验权限码 → 无权限返回 403
Authenticate(auth.jwt)
│ 反射读取 #[Permission] 注解(方法级)
│ 校验权限码 → 无权限返回 HTTP 403
│
▼
TenantMiddleware
ResolveTenant(tenant)
│
▼
DemoMiddleware
│ 读取 #[DemoAllow] 注解
DemoMiddleware(demo,认证路由组内)
│ 反射读取 #[DemoAllow] 注解
│ 演示环境 + 写操作 + 无注解 → 拦截返回 403
│
▼
LogMiddleware
│ 读取 #[Log] 注解
│ 记录操作日志
LogOperation(log.operation)
│ handle:反射读取 #[OperationLog] 注解,判断是否记录
│ terminate:响应后写入操作日志
│
▼
Controller 方法执行
```
## #[Log] 操作日志注解
## #[OperationLog] 操作日志注解
**文件:** `app/attribute/Log.php`
**读取方:** LogMiddleware
**文件:** `app/Attributes/OperationLog.php`
**读取方:** `LogOperation` 中间件(`ReflectionMethod`,微秒级,无缓存)
**作用域:** 方法
### 基本用法
```php
#[Log('用户管理-新增记录', Log::TYPE_ADD, '新增用户:{username}')]
// │ │ │
// │ │ └── 描述(支持 {param} 占位符)
// │ └── 操作类型(40种常量)
// └── 操作标题
#[OperationLog('用户管理-新增', OperationLog::TYPE_ADD, '新增用户:{username}')]
// │ │ │
// │ │ └── 描述(支持 {param} 占位符)
// │ └── 操作类型(30 种常量)
// └── 操作标题
```
### 参数说明
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `title` | string | ✓ | 操作标题,如"用户管理-新增记录" |
| `title` | string | ✓ | 操作标题,如"用户管理-新增" |
| `type` | int | ✗ | 操作类型常量,默认 0(由中间件按 HTTP 方法推断) |
| `description` | string | ✗ | 详细描述,支持 `{param}` 占位符 |
构造函数签名(来自项目实际代码):
```php
public function __construct(
public string $title,
public int $type = 0,
public string $description = '',
) {}
```
### 使用示例
```php
// 新增
#[Log('用户管理-新增记录', Log::TYPE_ADD, '新增用户:{username}')]
// 修改
#[Log('用户管理-修改记录', Log::TYPE_UPDATE, '修改用户ID:{id}')]
// 删除
#[Log('用户管理-删除记录', Log::TYPE_DELETE, '删除用户ID:{id}')]
// 新增 / 修改 / 删除
#[OperationLog('用户管理-新增', OperationLog::TYPE_ADD, '新增用户:{username}')]
#[OperationLog('用户管理-修改', OperationLog::TYPE_UPDATE, '修改用户ID:{id}')]
#[OperationLog('用户管理-删除', OperationLog::TYPE_DELETE, '删除用户ID:{id}')]
// 批量删除
#[Log('用户管理-批量删除记录', Log::TYPE_DELETE)]
#[OperationLog('用户管理-批量删除', OperationLog::TYPE_DELETE)]
// 导出(GET 请求也会记录)
#[Log('用户管理-导出数据', Log::TYPE_EXPORT)]
#[OperationLog('用户管理-导出数据', OperationLog::TYPE_EXPORT)]
// 导入
#[Log('用户管理-导入数据', Log::TYPE_IMPORT, '通过Excel导入用户数据')]
#[OperationLog('用户管理-导入数据', OperationLog::TYPE_IMPORT, '通过Excel导入用户数据')]
// 重置密码
#[Log('用户管理-重置密码', Log::TYPE_RESET, '重置用户ID:{id}的密码')]
#[OperationLog('用户管理-重置密码', OperationLog::TYPE_RESET, '重置用户ID:{id}的密码')]
// 登录(TYPE_LOGIN)
#[Log('系统登录-用户登录', Log::TYPE_LOGIN)]
// 清理缓存
#[OperationLog('字典管理-刷新缓存', OperationLog::TYPE_CLEAR)]
// 登出(TYPE_LOGOUT)
#[Log('系统登录-用户登出', Log::TYPE_LOGOUT)]
// 查询(GET 默认不记录,除非是导出/下载/导入类型)
#[Log('用户管理-查询分页记录', Log::TYPE_QUERY)]
// 查询(GET + TYPE_QUERY 不落库,仅作语义标注)
#[OperationLog('用户管理-查询分页', OperationLog::TYPE_QUERY)]
```
### 占位符替换
```php
#[Log('文章管理-删除记录', Log::TYPE_DELETE, '删除文章ID:{id}')]
#[OperationLog('文章管理-删除', OperationLog::TYPE_DELETE, '删除文章ID:{id}')]
// {id} 会被替换为 $request->param('id') 的值
// {id} 会被替换为请求参数中 id 的值
// 实际记录:"删除文章ID:42"
```
### 操作类型常量
### 操作类型常量(30 种)
| 常量 | 值 | 说明 | GET 是否记录 |
| 常量 | 值 | 说明 | GET 是否落库 |
|------|-----|------|-------------|
| `TYPE_ADD` | 1 | 新增 | — |
| `TYPE_UPDATE` | 2 | 修改 | — |
@@ -119,30 +122,37 @@ Controller 方法执行
| `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_APPROVE` | 7 | 审批 | — |
| `TYPE_REJECT` | 8 | 驳回 | — |
| `TYPE_SUBMIT` | 9 | 提交 | — |
| `TYPE_WITHDRAW` | 10 | 撤回 | — |
| `TYPE_CLOSE` / `TYPE_OPEN` | 11 / 12 | 关闭 / 开启 | — |
| `TYPE_ENABLE` / `TYPE_DISABLE` | 13 / 14 | 启用 / 禁用 | — |
| `TYPE_UPLOAD` / `TYPE_DOWNLOAD` | 15 / 16 | 上传 / 下载 | 下载 ✓ |
| `TYPE_SEND` / `TYPE_SYNC` | 17 / 18 | 发送 / 同步 | — |
| `TYPE_GENERATE` / `TYPE_CLEAR` | 19 / 20 | 生成 / 清理 | — |
| `TYPE_RESTORE` | 21 | 恢复 | — |
| `TYPE_LOGIN` / `TYPE_LOGOUT` | 22 / 23 | 登录 / 登出 | — |
| `TYPE_RESET` | 24 | 重置 | — |
| `TYPE_ASSIGN` / `TYPE_AUTHORIZE` | 25 / 26 | 分配 / 授权 | — |
| `TYPE_EXECUTE` | 27 | 执行 | — |
| `TYPE_PAUSE` / `TYPE_RESUME` | 28 / 29 | 暂停 / 恢复执行 | — |
| `TYPE_OTHER` | 99 | 其他 | — |
完整常量列表见 `app/attribute/Log.php`(共 40 种)。
(完整注释见 `app/Attributes/OperationLog.php`;GET 仅 IMPORT/EXPORT/DOWNLOAD 三类落库,见 [5.14 操作日志](/zh/core/operation-log.html)。)
## #[Permission] 权限注解
**文件:** `app/attribute/Permission.php`
**读取方:** AuthMiddleware
**作用域:** 方法 + 类(方法优先)
**文件:** `app/Attributes/Permission.php`
**读取方:** `Authenticate` 中间件(`ReflectionMethod`)
**作用域:** 方法
### 基本用法
```php
#[Permission('sys:user:add', '添加用户')]
// │ │
// │ └── 权限名称(用于提示和日志)
// │ └── 权限名称(菜单管理中显示、403 消息附带编码)
// └── 权限编码
```
@@ -151,193 +161,155 @@ Controller 方法执行
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `code` | string | ✓ | 权限编码,如 `sys:user:add` |
| `name` | string | ✗ | 权限名称,用于提示和日志 |
| `name` | string | ✗ | 权限名称,用于提示和菜单展示 |
### 使用示例
```php
// 方法级注解(最常用)
#[Permission('sys:user:add', '添加用户')]
public function add(): Json { ... }
#[Permission('sys:user:page', '用户分页')]
public function page(): Json { ... }
public function page(): JsonResponse { ... }
// 类级注解(该类所有方法都需此权限)
#[Permission('sys:user:manage', '用户管理')]
class UserController extends BaseController { ... }
// 与日志组合(最常见模式)
#[OperationLog('用户管理-新增', OperationLog::TYPE_ADD, '新增用户:{username}')]
#[Permission('sys:user:add', '添加用户')]
public function add(UserRequest $request): JsonResponse { ... }
// 不标注 = 该接口只需登录,无需特定权限
public function getOptions(): JsonResponse { ... }
```
### 优先级规则
### 校验规则
```text
1. 方法上有 #[Permission] → 使用方法的权限码
2. 方法上无 #[Permission],类上有 → 使用类的权限码
3. 方法和类都没有 → 跳过权限校验(所有已登录用户可访问)
1. 方法上有 #[Permission] → 使用方法的权限编码
2. 方法上无注解 → 跳过权限校验(所有已登录用户可访问)
3. uid=1 超级管理员 → 直接放行,不查询权限
4. 权限编码需与菜单表 permission 字段一致(菜单即权限)
```
### 超级管理员
```text
uid=1 的用户为超级管理员,跳过所有权限校验。
AuthMiddleware 检测到 uid=1 时直接放行,不查询权限表。
```
详见 [5.4 权限管理(RBAC)](/zh/core/rbac.html)。
## #[DemoAllow] 演示放行注解
**文件:** `app/attribute/DemoAllow.php`
**读取方:** DemoMiddleware
**作用域:** 方法
**参数:** 无
**文件:** `app/Attributes/DemoAllow.php`
**读取方:** `DemoMiddleware`(反射读取,异常静默视为未标注)
**作用域:** 方法(仅 `TARGET_METHOD`,不支持类级)
**参数:** 无(标记型注解)
### 基本用法
```php
// 标注后,演示环境下该写操作不受拦截
#[DemoAllow]
public function refreshCache(): Json { ... }
#[DemoAllow]
public function login(): Json { ... }
public function refreshCache(): JsonResponse { ... }
```
### 工作原理
```text
DemoMiddleware 检查逻辑:
1. APP_DEMO = false → 放行(非演示环境,env('app_demo'))
2. APP_DEMO = true + GET 请求 → 放行
3. APP_DEMO = true + 写操作(POST/PUT/DELETE)
1. DEMO_MODE = false → 放行(非演示环境,config('app.demo_mode'))
2. DEMO_MODE = true + GET 请求 → 放行
3. DEMO_MODE = true + 写操作(POST/PUT/DELETE/PATCH)
├─ 方法有 #[DemoAllow] → 放行
└─ 方法无 #[DemoAllow] → 拦截,返回 403
└─ 方法无 #[DemoAllow] → 拦截,返回 HTTP 403
```
### 典型场景
```php
// 登录接口:演示环境也需要允许登录
// 缓存刷新:仅清缓存不改业务数据,演示环境放行
#[OperationLog('字典管理-刷新缓存', OperationLog::TYPE_CLEAR)]
#[Permission('sys:dict:update', '修改字典')]
#[DemoAllow]
public function login(): Json { ... }
// 缓存刷新:演示环境也需要允许
#[DemoAllow]
public function refreshCache(): Json { ... }
public function refreshCache(): JsonResponse { ... }
// 用户新增:演示环境禁止(无 #[DemoAllow])
public function add(): Json { ... }
public function add(UserRequest $request): JsonResponse { ... }
```
详见 [5.7 演示模式](/zh/core/demo-mode.html)。
## 注解组合使用
```php
// 操作日志 + 权限校验(最常见组合)
#[Log('用户管理-新增记录', Log::TYPE_ADD, '新增用户:{username}')]
#[OperationLog('用户管理-新增', OperationLog::TYPE_ADD, '新增用户:{username}')]
#[Permission('sys:user:add', '添加用户')]
public function add(): Json { ... }
public function add(UserRequest $request): JsonResponse { ... }
// 操作日志 + 权限校验 + 演示放行
#[Log('系统登录-用户登录', Log::TYPE_LOGIN)]
#[Permission('sys:login:do', '登录')]
#[DemoAllow]
public function login(): Json { ... }
// 仅权限校验(查询接口不需要日志)
// 仅权限校验(查询接口不需要落日志)
#[Permission('sys:user:page', '用户分页')]
public function page(): Json { ... }
public function page(): JsonResponse { ... }
// 仅日志(不需要权限校验的公开接口)
#[Log('系统登录-获取验证码', Log::TYPE_OTHER)]
public function captcha(): Json { ... }
// 操作日志 + 权限 + 演示放行(三注解齐全的特殊场景)
#[OperationLog('字典管理-刷新缓存', OperationLog::TYPE_CLEAR)]
#[Permission('sys:dict:update', '修改字典')]
#[DemoAllow]
public function refreshCache(): JsonResponse { ... }
```
## AttributeService
## 反射读取机制
**文件:** `app/service/AttributeService.php`
**职责:** 通过反射读取注解,带缓存机制
### 方法清单
| 方法 | 说明 | 返回值 |
|------|------|--------|
| `getLog($class, $method)` | 获取方法上的 Log 注解 | `Log\|null` |
| `getPermission($class, $method)` | 获取方法/类上的 Permission 注解 | `Permission\|null` |
| `hasDemoAllow($class, $method)` | 判断方法是否有 DemoAllow 注解 | `bool` |
### 使用示例
三个中间件均直接使用 `ReflectionMethod` 读取注解(项目**没有**独立的注解读取服务,逻辑内聚在各中间件中):
```php
// 获取方法上的 Log 注解
$log = AttributeService::getLog('app\controller\UserController', 'add');
if ($log) {
$title = $log->title; // '用户管理-新增记录'
$type = $log->type; // 1 (TYPE_ADD)
$desc = $log->description; // '新增用户:{username}'
}
// 通用读取模式(Authenticate / LogOperation / DemoMiddleware 同款)
$route = $request->route();
$controllerClass = $route->getControllerClass();
$method = $route->getActionMethod();
// 获取方法上的 Permission 注解(方法优先,其次类)
$perm = AttributeService::getPermission('app\controller\UserController', 'add');
if ($perm) {
$code = $perm->code; // 'sys:user:add'
$name = $perm->name; // '添加用户'
}
$reflection = new \ReflectionMethod($controllerClass, $method);
$attrs = $reflection->getAttributes(Permission::class); // 换成目标注解类
// 判断是否有 DemoAllow 注解
$allowed = AttributeService::hasDemoAllow('app\controller\DictController', 'refreshCache');
// true / false
if (! empty($attrs)) {
$permission = $attrs[0]->newInstance(); // $permission->code / ->name
}
```
### 缓存机制
```text
缓存键:类名@方法名
缓存位置:static::$localCache(请求级缓存)
缓存策略:同一请求内不重复反射,跨请求重新解析
示例:
"app\controller\UserController@add" → Log 实例 + Permission 实例
```
:::tip 性能说明
反射读取开销为微秒级(单请求仅 2~3 次方法反射),且中间件对注解缺失场景做了静默降级(反射失败视为无注解,不阻断请求)——无需缓存,避免开发阶段改注解后需等缓存过期。
:::
## 创建自定义注解
注解类结构参考(摘自 `app/Attributes/Permission.php` 实际代码):
```php
// 1. 定义注解类
// app/attribute/RateLimit.php
namespace app\attribute;
namespace App\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class RateLimit
class Permission
{
/**
* @param string $code 权限编码(鉴权唯一依据,如 sys:user:add)
* @param string $name 权限名称(用于展示,如"添加用户")
* @param string $description 权限描述(可选,补充说明)
*/
public function __construct(
public int $max = 100, // 最大请求数
public int $window = 60, // 窗口期(秒)
public string $code,
public string $name = '',
public string $description = '',
) {}
}
// 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
}
}
```
新建自定义注解的三个步骤:
1. **定义注解类**:在 `app/Attributes/` 下按上述结构创建类(`TARGET_METHOD` 标注在方法上,构造参数即注解参数);
2. **在控制器方法上使用**:如 `#[Permission('sys:user:add', '添加用户')]`;
3. **在中间件中读取**:用上一节的反射模式 `getAttributes(你的注解类::class)` 读取并处理。
## 最佳实践
| 实践 | 说明 |
|------|------|
| 注解即文档 | 通过注解即可了解接口的操作类型、权限要求 |
| 写操作必须标注 `#[Log]` | 删除、重置密码等敏感操作必须记录日志 |
| 权限码统一格式 | 使用 `sys:{module}:{action}` 格式 |
| 写操作必须标注 `#[OperationLog]` | 删除、重置密码等敏感操作必须记录日志 |
| 权限码统一格式 | 使用 `sys:{module}:{action}` 格式,与菜单 `permission` 一致 |
| 描述加关键参数 | 用 `{param}` 占位符记录业务主键,便于追溯 |
| 查询不标注 `#[Log]` | 普通查询不记录日志,避免日志膨胀 |
| 组合使用 | `#[Log]` + `#[Permission]` 组合是最常见的模式 |
| 查询注解仅作语义标注 | `TYPE_QUERY` 不落库,勿依赖其做审计 |
| 组合使用 | `#[OperationLog]` + `#[Permission]` 组合是最常见的模式 |
+182 -245
View File
@@ -8,22 +8,21 @@
```text
┌─────────────────────────────────────────────────────────────────────┐
│ 认证授权全景 │
│ 认证授权全景 │
│ │
│ ① 登录 POST /api/login │
│ 用户名+密码+验证码 → 校验 → 签发 access_token + refresh_token │
│ ① 登录 POST /api/login(公开接口) │
│ 用户名+密码+验证码(key/code) → 校验 → 签发双令牌 + 写登录日志 │
│ │
│ ② 请求 API GET/POST /api/xxx │
│ Bearer token → AuthMiddleware → 权限校验 → 注入 userInfo │
│ ② 请求 API GET/POST /api/xxx(auth.jwt 组内) │
│ Bearer token → Authenticate → 权限校验 → 注入 userInfo │
│ │
│ ③ Token 过期 响应 code=401 │
│ refresh_token → POST /api/oauth2/token → 新令牌对 │
│ ③ Token 过期 响应 code=401(HTTP 200) │
│ refresh_token → POST /api/refresh → 新令牌对(旧 jti 一次性作废) │
│ │
│ ④ 租户隔离 TenantMiddleware │
│ 解析租户 → 注入 tenantId → Logic 层自动过滤 │
│ ④ 租户隔离 ResolveTenant │
│ 解析租户 → 注入 tenantId → Service 层自动过滤 │
│ │
│ ⑤ 登出 前端清除 localStorage │
│ (JWT 无状态,服务端无需处理) │
│ ⑤ 登出 POST /api/logout → revokeToken(refresh 即时失效) │
└─────────────────────────────────────────────────────────────────────┘
```
@@ -33,58 +32,52 @@
```text
POST /api/login
{ "username": "admin", "password": "123456", "code": "a3Bx", "key": "xxx" }
{ "username": "admin", "password": "123456", "key": "captcha-uuid", "code": "a3Bx" }
│
▼
┌─────────────────────────────────────────────────────────┐
│ LoginController::login() │
│ AuthController::login(LoginRequest $request) │
│ │
│ 1. getParams() 获取请求参数 │
│ 2. validate($params, LoginValidate::class . '.login') │
│ │ 失败 → {"code":422, "msg":"参数验证失败"} │
│ 1. LoginRequest 类型注入 → rules() 格式校验 │
│ │ 失败 → {"code":1, "msg":"参数验证失败"} │
│ ▼ │
│ 3. LoginLogic::login($username, $password, $code, $key) │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ LoginLogic::login() │
│ │
│ 1. CaptchaService::check($code, $key) │
│ │ 失败 → throw "验证码错误" │
│ 2. 读取 key / code 参数(与 captcha 响应字段命名一致) │
│ CaptchaService::verify($key, $code) │
│ │ 仅当 config('captcha.length') > 0 时启用 │
│ │ 失败 → fail("请输入验证码" / "验证码错误") │
│ ▼ │
│ 2. JwtService::login($username, $password) │
│ 3. JwtService::login($username, $password) │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ JwtService::login() │
│ │
│ 1. User::withoutGlobalScope(['soft_delete']) │
│ ->where('username', $username)->find() │
│ │ 不存在 → throw "用户名或密码错误" │
│ 1. User::where('username', $username)->first() │
│ │ 不存在 → BizException("用户名或密码错误") │
│ ▼ │
│ 2. $user->status != 1 │
│ │ 禁用 → throw "账号已被禁用" │
│ 2. $user->status 禁用 │
│ │ → BizException("账号已被禁用") │
│ ▼ │
│ 3. PasswordService::verify($password, $hashed, $salt) │
│ │ 不匹配 → throw "用户名或密码错误" │
│ │ 不匹配 → BizException("用户名或密码错误") │
│ ▼ │
│ 4. Jwt::createAccessToken($uid, $username) │
│ Jwt::createRefreshToken($uid, $username) │
│ 4. createTokenPair($user) │
│ │ 生成 jti(UUID) → 签发 access + refresh │
│ │ Redis::setex("refresh:{uid}:{jti}", 7天, token) │
│ │ loadPermissions() → 缓存 perms:{uid} │
│ ▼ │
│ 5. 返回 { access_token, refresh_token, user } │
│ 5. 返回 { access_token, refresh_token, token_type, │
│ expires_in, user } │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ LoginController 续 │
│ AuthController 续 │
│ │
│ 成功 → LoginLogic::recordLoginLog(成功) │
│ → $this->success($result, '登录成功', true) │
│ │
│ 失败 → LoginLogic::recordLoginLog(失败) │
│ → $this->fail($e->getMessage()) │
│ 成功 → writeLoginLog(成功) → success($result,'登录成功') │
│ 失败 → writeLoginLog(失败) → fail($e->getMessage()) │
│ (登录日志成功/失败均记录,含 IP 归属地、浏览器) │
└─────────────────────────────────────────────────────────┘
```
@@ -99,8 +92,8 @@ Content-Type: application/json
{
"username": "admin",
"password": "123456",
"code": "a3Bx",
"key": "captcha_xxx"
"key": "5c62cae9-3bdb-4356-bc4a-c4c61b7c3bea",
"code": "a3Bx"
}
```
@@ -112,29 +105,34 @@ Content-Type: application/json
"ok": true,
"msg": "登录成功",
"data": {
"accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"tokenType": "Bearer",
"expiresIn": 7200,
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"token_type": "Bearer",
"expires_in": 7200,
"user": {
"id": 1,
"username": "admin",
"realname": "管理员",
"avatar": "/uploads/user/avatar/avatar.jpg"
"avatar": "user/avatar/20260923/100429_e8FAKf9r.png"
}
}
}
```
:::tip 登录/刷新接口 raw 返回
服务端原始数组键为 `access_token` 等 snake_case;登录、刷新接口由 `AuthController` 以 `success(..., true)` raw 返回,**跳过**驼峰转换,与前端 `data.access_token` 读取方式一致(一般接口仍经驼峰转换,如 `create_time` → `createTime`)。
:::
**失败响应:**
| 场景 | code | msg |
|------|------|-----|
| 参数格式错误 | 1 | 参数验证失败(data 为字段明细) |
| 验证码缺失 | 1 | 请输入验证码 |
| 验证码错误 | 1 | 验证码错误 |
| 用户不存在 | 1 | 用户名或密码错误 |
| 密码错误 | 1 | 用户名或密码错误 |
| 账号禁用 | 1 | 账号已被禁用 |
| 参数缺失 | 422 | 用户名不能为空 / 密码不能为空 |
## 二、接口认证流程
@@ -146,102 +144,83 @@ Authorization: Bearer eyJ...
│
▼
┌─────────────────────────────────────────────────────────┐
│ CorsMiddleware(全局) │
│ OPTIONS 预检 → 返回 204 + CORS 头 │
│ 普通请求 → 设置跨域头,放行 │
│ ProxyMiddleware(全局 api 组) │
│ 受信代理校验 → 还原真实 REMOTE_ADDR │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 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. 放行 │
│ Cors(全局 api 组) │
│ OPTIONS 预检 → 204 + CORS 头;普通请求 → 追加 CORS 头 │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ TenantMiddleware │
│ │
│ 1. 从 JWT payload 获取 uid │
│ 2. 查询用户所属租户 │
│ 3. $request->tenantId = 租户ID │
│ 4. $request->tenantInfo = 租户信息 │
│ │ 租户禁用/过期 → {"code":403, "msg":"租户异常"} │
│ ▼ │
│ 5. 放行 │
│ ForceJsonResponse(全局 api 组) │
│ Accept 强制 application/json │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ DemoMiddleware │
│ Authenticate(auth.jwt,路由组) │
│ │
│ 1. 检查 DEMO 配置(env('app_demo')) │
│ 2. 非演示模式 → 放行 │
│ 3. 演示模式 + 写操作(POST/PUT/DELETE) │
│ │ 未标注 #[DemoAllow] → {"code":403, "msg":"演示环境"} │
│ ▼ │
│ 4. 放行 │
│ 1. 提取 Bearer token(公开接口在组外,不经过此处) │
│ │ 缺失 → HTTP 200 {"code":401, "msg":"未提供认证令牌"}│
│ 2. JwtService::parseToken()(type 必须为 access) │
│ │ 过期/签名错 → HTTP 200 │
│ 3. 注入 attributes['userInfo'] │
│ 4. 反射 #[Permission] 注解 → checkPermission() │
│ │ uid=1 跳过 → Redis perms:{uid} → Cache → 查库 │
│ │ 无权限 → HTTP 403 {"code":403, "msg":"无访问权限…"} │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ LogMiddleware │
│ ResolveTenant(tenant,路由组) │
│ │
│ 1. 读取 #[Log] 注解 │
│ 2. 记录操作日志(异步/同步) │
│ 3. 放行 │
│ 1. 无 userInfo → 放行(未认证请求不注入租户) │
│ 2. uid=1 → tenantId=0, isSuperAdmin=true │
│ 3. 非超管 → 查库 tenant_id │
│ │ 用户不存在 → HTTP 200 │
│ │ 租户不存在/禁用/过期 → HTTP 403 │
│ 4. 注入 attributes['tenantId' / 'tenantInfo'] │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ UserController::page() │
│ │
│ return parent::_index($this->logic); │
│ → Logic::pageList() → Model → Database │
│ LogOperation(log.operation,路由组) │
│ handle:收集上下文、读 #[OperationLog]、判断是否记录 │
│ terminate:响应后写入操作日志(不阻塞业务) │
└─────────────────────────┬───────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ UserController::page() │
│ return $this->_page($this->service); │
│ → Service::pageList() → Model → Database │
└─────────────────────────┬───────────────────────────────┘
│
▼
JSON 响应返回
```
(各中间件源码级解析见 [4.2 请求处理链路](/zh/architecture/request-flow.html))
### 权限校验详解
```text
AuthMiddleware::checkPermission($uid, $permissionCode)
Authenticate::checkPermission($uid, $permissionCode)
│
▼
1. $uid === 1(超级管理员)
│ 是 → return true(跳过校验)
▼
2. UserRole::where('user_id', $uid)->column('role_id')
│ 无角色 → return false
2. Redis::get("perms:{$uid}") ← 一级缓存
│ 命中 → in_array('*:*:*') 或 in_array(code) → 通过
│ Redis 不可用 → 降级
▼
3. RoleMenu → Menu::whereIn('role_id', $roleIds)
.where('type', 1) // type=1 为权限节点
.column('permission')
3. Cache::remember("perms_fallback:{$uid}", 300) ← 二级缓存(5 分钟)
│ 命中 → 匹配判断
▼
4. in_array($permissionCode, $permissions)
4. loadPermissionsFromDb($uid) ← 三级:查库并回填缓存
UserRole(用户角色)→ RoleMenu join Menu
→ 条件:menu 未删除、type=1、permission 非空
│ in_array($permissionCode, $permissions)
│ 包含 → return true(放行)
│ 不包含 → return false(返回 403)
│ 不包含 → return false(HTTP 403)
```
**权限码格式:** `sys:{模块名}:{操作}`
@@ -252,73 +231,57 @@ sys:user:update 修改用户
sys:user:delete 删除用户
sys:user:page 用户分页
sys:article:add 添加文章
sys:article:list 文章列表
sys:article:detail 文章详情
```
菜单/角色菜单数据变更时 `MenuObserver` / `RoleMenuObserver` 自动清除相关缓存,权限调整即时生效。
## 三、Token 刷新流程
### 流程图
```text
前端检测到 code=401
客户端持有 refresh_token,需要换取新令牌对
(第三方接入调用;管理后台前端当前未内置自动续签,见 8.6)
│
▼
┌─────────────────────────────────────────────────────────┐
│ 前端响应拦截器 │
│ │
│ 1. 从 localStorage 读取 refresh_token │
│ 2. POST /api/oauth2/token │
│ { grant_type: "refresh_token", refresh_token: "..." } │
│ 客户端请求 │
│ POST /api/refresh { refresh_token: "..." } │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ LoginController::token() │
│ │
│ 1. 判断 grant_type === 'refresh_token' │
│ 2. LoginLogic::refreshToken($refreshToken) │
│ AuthController::refresh() │
│ 1. 读取 refresh_token(缺失 → fail("缺少refresh_token")│
│ 2. JwtService::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. 解析令牌 → 失败 → BizException("无效的refresh_token") │
│ 2. type === 'refresh' → 否则同上 │
│ 3. Lua 原子脚本:GETDEL refresh:{uid}:{jti} │
│ │ 不存在(已用过/已吊销)→ "refresh_token已失效" │
│ 4. User 状态校验 → "用户不存在或已被禁用" │
│ 5. createTokenPair() → 新 jti 签发新令牌对写入 Redis │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 前端响应拦截器(续) │
│ │
│ 1. 更新 localStorage 中的 access_token │
│ 2. 更新 localStorage 中的 refresh_token │
│ 3. 用新 access_token 重试原失败请求 │
│ 客户端处理结果 │
│ 1. 保存返回的新 access_token / refresh_token(均为新值) │
│ 2. 后续请求携带新 access_token │
└─────────────────────────────────────────────────────────┘
```
### 刷新接口详情
**请求:**
**请求(主入口):**
```bash
POST /api/oauth2/token
POST /api/refresh
Content-Type: application/json
{
"grant_type": "refresh_token",
"refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
}
{ "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..." }
```
**成功响应:**
@@ -327,12 +290,12 @@ Content-Type: application/json
{
"code": 0,
"ok": true,
"msg": "success",
"msg": "刷新成功",
"data": {
"accessToken": "eyJ...(新access_token)",
"refreshToken": "eyJ...(新refresh_token)",
"tokenType": "Bearer",
"expiresIn": 7200
"access_token": "eyJ...(新access_token)",
"refresh_token": "eyJ...(新refresh_token)",
"token_type": "Bearer",
"expires_in": 7200
}
}
```
@@ -341,10 +304,15 @@ Content-Type: application/json
| 场景 | code | msg |
|------|------|-----|
| 缺少 refresh_token | 1 | 缺少refresh_token参数 |
| refresh_token 无效/过期 | 1 | 无效的refresh_token |
| 缺少 refresh_token | 1 | 缺少refresh_token |
| refresh_token 无效/类型错 | 1 | 无效的refresh_token |
| 旧令牌已用过(重放) | 1 | refresh_token已失效 |
| 用户不存在/禁用 | 1 | 用户不存在或已被禁用 |
:::warning 一次性刷新(防重放)
旧 refresh_token 刷新后立即从 Redis 删除(Lua 原子操作),重放旧令牌会返回"refresh_token已失效"。客户端必须保存返回的**新** refresh_token,否则下次无法续签。
:::
## 四、完整请求示例
以"查询用户分页"为例,展示一次认证请求的完整链路:
@@ -355,36 +323,32 @@ Content-Type: application/json
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
│
▼
② CorsMiddleware → 设置跨域头,放行
② ProxyMiddleware / Cors / ForceJsonResponse → 依次放行
│
▼
③ AuthMiddleware
│ getTokenFromHeader() → "eyJ..."
│ Jwt::decode() → { uid:1, username:"admin", type:"access", exp:... }
③ Authenticate
│ 提取 token → parseToken() → { uid:1, username:"admin", type:"access", jti, exp }
│ type === 'access' ✓
│ $request->userInfo = $decoded
│ checkPermission(1, 'sys:user:page')
│ uid=1 → 超级管理员,跳过
▼
④ TenantMiddleware
│ 查询用户租户 → tenantId=1
│ $request->tenantId = 1
▼
⑤ DemoMiddleware → 非演示模式,放行
│ 注入 attributes['userInfo']
│ checkPermission(1, 'sys:user:page') → uid=1 超管跳过 ✓
│
▼
⑥ LogMiddleware → GET 查询不记录日志,放行
④ ResolveTenant → uid=1 → tenantId=0, isSuperAdmin=true
│
▼
⑦ 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
⑤ LogOperation → GET 普通查询不记录(非导入导出类型),放行
│
▼
⑥ UserController::page()
│ return $this->_page($this->service)
│
▼
⑦ UserService::pageList($params)
│ buildQuery:LIKE/精确匹配 + 软删除全局范围
│ → SELECT ... FROM laravel_user WHERE is_delete = 0 ...
│
▼
⑧ 返回 JSON(驼峰转换后)
{
"code": 0,
"ok": true,
@@ -392,84 +356,57 @@ Content-Type: application/json
"data": {
"records": [...],
"total": 42,
"size": 20,
"current": 1,
"size": 20,
"pages": 3
}
}
```
## 五、前端自动刷新 Token 实现
## 五、前端 401 处理(实际实现)
`ui/` 管理后台当前**未内置自动续签**:access_token 过期(code=401)时直接提示重新登录(摘自 `ui/src/utils/http/axios/index.ts` 实际代码):
```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;
}
}
);
// code === 401(ResultEnum.TIMEOUT)
case ResultEnum.TIMEOUT:
if (router.currentRoute.value?.name === PageEnum.BASE_LOGIN_NAME) return;
ElMessageBox.confirm('登录身份已失效,请重新登录!', '提示', {
type: 'warning',
})
.then(() => {
storage.clear(); // 清除本地登录态
window.location.href = PageEnum.BASE_LOGIN; // '/login'
})
.catch(() => {});
break;
```
## 六、OAuth2 兼容接口
自动续签所需的后端能力已就绪([三、Token 刷新流程](#三-token-刷新流程) 的 `POST /api/refresh`),后续前端升级时可在此处接入刷新与请求重试逻辑。
## 六、登出与 OAuth2 兼容接口
### 登出(令牌即时失效)
```bash
# 携带 access_token,吊销对应 refresh_token
POST /api/logout
Authorization: Bearer <access_token>
```
`JwtService::revokeToken()` 解析 `uid + jti` 后删除 Redis 中的 `refresh:{uid}:{jti}`——旧 access_token 到期后将无法续签。改密、封号等场景调用 `revokeAllTokens($userId)` 一次性吊销该用户**全部**令牌并清除权限缓存(踢下线)。
### OAuth2 兼容接口
系统同时提供 OAuth2 风格的 Token 接口,兼容标准 OAuth2 客户端:
| 接口 | 方法 | 说明 |
|------|------|------|
| `/api/oauth2/token` | POST | 获取令牌(grant_type=password 或 refresh_token) |
| `/api/oauth2/remove/{token}` | DELETE | 吊销令牌 |
| `/api/oauth2/check/token` | GET | 检测令牌有效性 |
| 接口 | 方法 | 认证 | 说明 |
|------|------|------|------|
| `/api/oauth2/token` | POST | 公开 | 获取令牌(grant_type=password 或 refresh_token) |
| `/api/oauth2/check/token` | GET | 公开 | 检测令牌有效性 |
| `/api/oauth2/remove/{token}` | DELETE | 认证组 | 吊销令牌 |
### 密码模式获取令牌
#### 密码模式获取令牌
```bash
POST /api/oauth2/token
@@ -478,7 +415,7 @@ Content-Type: application/x-www-form-urlencoded
grant_type=password&username=admin&password=123456
```
### 刷新模式获取令牌
#### 刷新模式获取令牌
```bash
POST /api/oauth2/token
+130 -228
View File
@@ -1,7 +1,7 @@
# 5.3 验证码机制
:::tip 概述
CaptchaService 提供图片验证码的生成与验证,用于登录等需要人机校验的场景。验证码基于 PHP GD 库生成,采用 6 位字母数字混合字符(去除易混淆字符 0O、1lI),支持防刷限流和万能验证码。
CaptchaService 提供图片验证码的生成与验证,用于登录等需要人机校验的场景。验证码基于 PHP GD 库生成,采用 6 位字母数字混合字符(去除易混淆字符 0O、1lI),Redis 存储、Lua 脚本原子校验(一次性),支持防刷限流和万能验证码(仅调试模式生效)。
:::
## 工作流程
@@ -13,18 +13,18 @@ CaptchaService 提供图片验证码的生成与验证,用于登录等需要
前端 后端
│ │
│── GET /api/captcha ──────────────────►│
│ ?clientIp=192.168.1.1 │
│── GET /api/captcha ──────────────────►│ (公开接口,无需认证)
│ │
│ ┌────────┴────────┐
│ │ CaptchaService │
│ │ CaptchaController│
│ │ ::generate() │
│ └────────┬────────┘
│ │
│ ┌────────┴────────┐
│ │ 1. 防刷检查 │
│ │ checkRateLimit() │
│ │ 超限 → 返回等待 │
│ │ checkRateLimit( │
│ │ request()->ip())│ ← IP 经 ProxyMiddleware 还原
│ │ 超限 → 返回等待 │
│ └────────┬────────┘
│ │
│ ┌────────┴────────┐
@@ -60,9 +60,9 @@ CaptchaService 提供图片验证码的生成与验证,用于登录等需要
前端 后端
│ │
│── POST /api/login ──────────────────►│
│ { username, password, │
│ code: "AB3K7M", │
│ key: "550e8400-..." } │
│ { username, password, │
│ code: "AB3K7M", │
│ key: "550e8400-..." } │
│ │
│ ┌────────┴────────┐
│ │ CaptchaService │
@@ -71,13 +71,17 @@ CaptchaService 提供图片验证码的生成与验证,用于登录等需要
│ │
│ ┌────────┴────────┐
│ │ 1. 万能验证码? │
│ │ 匹配 → 直接通过 │
│ │ (仅 app.debug) │
│ │ 匹配 → 直接通过 │
│ └────────┬────────┘
│ │
│ ┌────────┴────────┐
│ │ 2. Redis 查询 │
│ │ 2. Lua 原子脚本 │
│ │ GETDEL │
│ │ captcha:{key} │
│ │ 不存在 → 已过期 │
│ │ (取值即删除, │
│ │ 一次性使用) │
│ └────────┬────────┘
│ │
│ ┌────────┴────────┐
@@ -86,17 +90,12 @@ CaptchaService 提供图片验证码的生成与验证,用于登录等需要
│ └────────┬────────┘
│ │
│ ┌────────┴────────┐
│ │ 4. 删除缓存 │
│ │ 一次性使用 │
│ │ 4. 继续登录流程 │
│ │ JwtService:: │
│ │ login() │
│ └────────┬────────┘
│ │
│ ┌────────┴────────┐
│ │ 5. 继续登录流程 │
│ │ JwtService:: │
│ │ login() │
│ └────────┬────────┘
│ │
│◄── { access_token, ... } ────────────│
│◄── { access_token, ... } ─────────────│
```
## 接口说明
@@ -124,6 +123,12 @@ GET /api/captcha
- `key`:验证码唯一标识(UUID v4 格式),登录时需回传
- `captcha`:Base64 编码的图片,可直接用于 `<img src="...">`
获取超限时:
```json
{ "code": 1, "ok": false, "msg": "请求过于频繁,请 43 秒后重试", "data": null }
```
### 登录时验证
```bash
@@ -138,13 +143,18 @@ Content-Type: application/json
}
```
:::tip 参数命名约定
登录参数 `key` / `code` 与 `/api/captcha` 响应字段命名保持一致(不是 `captchaKey` / `captchaCode`)。
:::
### 错误响应
| 场景 | code | msg |
|------|------|-----|
| 验证码过期/不存在 | 1 | 验证码错误 |
| 验证码不匹配 | 1 | 验证码错误 |
| 获取频率超限 | 1 | 请求过于频繁,请等待 N 秒 |
| 获取频率超限 | 1 | 请求过于频繁,请 N 秒后重试 |
| 未配置验证码长度(=0) | — | 跳过验证码校验(仅调试用途) |
:::tip 验证码错误统一提示
无论验证码是过期、不存在还是不匹配,统一提示"验证码错误",不暴露具体原因,防止攻击者探测。
@@ -155,11 +165,11 @@ Content-Type: application/json
```text
Redis Key 格式:
captcha:{uuid-key}
captcha:{uuid-key} ← KEY_PREFIX = 'captcha:'
值:验证码小写(如 "ab3k7m")
TTL:300 秒(5分钟)
captcha_rate:{client-ip}
captcha:rate:{client-ip} ← RATE_KEY_PREFIX = 'captcha:rate:'
值:请求次数计数
TTL:60 秒(窗口期)
```
@@ -167,16 +177,19 @@ captcha_rate:{client-ip}
```text
示例:
captcha:550e8400-e29b-41d4-a716-446655440000 → "ab3k7m" (TTL 300s)
captcha_rate:192.168.1.1 → "3" (TTL 60s)
captcha:rate:192.168.1.1 → "3" (TTL 60s)
```
## 核心代码
```php
// app/service/CaptchaService.php
// app/Services/CaptchaService.php(节选)
class CaptchaService
{
const KEY_PREFIX = 'captcha:';
const RATE_KEY_PREFIX = 'captcha:rate:';
/**
* 生成验证码
*
@@ -185,33 +198,21 @@ class CaptchaService
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)];
}
$length = config('captcha.length', 6);
$chars = config('captcha.chars', 'ABCDEFGHJKLMNPQRSTUVWXYZ2346789');
$expire = config('captcha.expire', 300);
// 2. 生成随机字符(random_int 密码学安全随机)
// 3. 生成 UUID v4 格式的唯一标识
$key = self::uuid();
// 4. 存入 Redis(小写存储,5 分钟过期)
$redis = self::getRedis();
$redis->setex(self::KEY_PREFIX . $key, $expire, strtolower($code));
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);
// 5. 从配置读取图片尺寸,GD 渲染验证码图片并转为 base64
$width = config('captcha.image_width', 160);
$height = config('captcha.image_height', 50);
$imageBase64 = self::renderImage($code, $width, $height);
return [
'key' => $key,
'captcha' => $imageBase64,
];
return ['key' => $key, 'captcha' => $imageBase64];
}
/**
@@ -222,43 +223,38 @@ class CaptchaService
*/
public static function checkRateLimit(string $clientIp): int
{
$window = (int)env('captcha.captcha_rate_window', 60);
$limit = (int)env('captcha.captcha_rate_limit', 10);
$window = config('captcha.rate_window', 60);
$limit = config('captcha.rate_limit', 10);
$redis = self::getRedis();
$rateKey = self::RATE_KEY_PREFIX . $clientIp;
$count = (int)$redis->get($rateKey);
$rateKey = self::RATE_KEY_PREFIX.$clientIp;
$count = (int) Redis::get($rateKey);
if ($count >= $limit) {
$ttl = $redis->ttl($rateKey);
$ttl = Redis::ttl($rateKey);
return $ttl > 0 ? $ttl : $window;
}
$redis->incr($rateKey);
Redis::incr($rateKey);
if ($count === 0) {
$redis->expire($rateKey, $window);
Redis::expire($rateKey, $window);
}
return 0;
}
/**
* 验证验证码(一次性使用)
*
* @param string $key 验证码 key
* @param string $code 用户输入的验证码
* @param bool $deleteVerify 验证后是否删除
* @return bool 是否验证通过
* 验证验证码(Lua 原子取值即删除,一次性使用)
*/
public static function verify(string $key, string $code, bool $deleteVerify = true): bool
public static function verify(string $key, string $code): bool
{
if (empty($code)) {
return false;
}
// 万能验证码:配置了且匹配则跳过验证
$bypassCode = env('captcha.captcha_bypass_code', '');
if (!empty($bypassCode) && strtolower($code) === strtolower($bypassCode)) {
// 万能验证码:配置了且匹配则跳过验证(仅 app.debug=true 时生效)
$bypassCode = config('captcha.bypass_code', '');
if (! empty($bypassCode) && config('app.debug', false)
&& strcasecmp($code, $bypassCode) === 0) {
return true;
}
@@ -266,31 +262,28 @@ class CaptchaService
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);
// Lua 脚本原子完成"读取并删除",杜绝并发重放
$cached = Redis::eval($script, 1, self::KEY_PREFIX.$key);
if (! $cached) {
return false; // 已过期或不存在
}
// 大小写不敏感比较(Redis 中已存为小写)
return strtolower($code) === $cachedCode;
return strcasecmp($code, $cached) === 0;
}
}
```
:::tip Lua 原子性
校验使用 `Redis::eval` 执行 Lua 脚本原子完成 GETDEL(取值并删除)——即使两个并发请求携带同一验证码,也只有一个能通过,彻底杜绝验证阶段的重放。
:::
## 前端集成
```vue
<template>
<el-form-item>
<el-input v-model="form.captchaCode" placeholder="验证码" />
<el-input v-model="form.code" placeholder="验证码" />
<img :src="captchaImage" @click="refreshCaptcha" style="cursor: pointer;" />
</el-form-item>
</template>
@@ -299,141 +292,12 @@ class CaptchaService
import { getCaptcha } from '@/api/common/user';
const captchaImage = ref('');
const form = ref({ captchaKey: '', captchaCode: '' });
const form = ref({ key: '', code: '' });
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;
}
captchaImage.value = res.captcha; // 返回字段是 captcha(Base64),不是 image
};
onMounted(() => refreshCaptcha());
@@ -446,35 +310,73 @@ onMounted(() => refreshCaptcha());
|------|------|
| 页面加载时 | `onMounted` 自动获取 |
| 点击图片时 | 用户手动刷新 |
| 登录失败后 | 自动刷新并清空输入 |
| 登录失败后 | 自动刷新并清空输入(一次性已消费) |
| 验证码过期后 | 用户点击图片重新获取 |
## 配置项
```ini
# .env(config/captcha.php 读取)
CAPTCHA_LENGTH=6 # 验证码字符长度(4-6 位)
CAPTCHA_CHARS=ABCDEFGHJKLMNPQRSTUVWXYZ2346789 # 字符集(已排除 0O、1lI)
CAPTCHA_EXPIRE=300 # 有效期(秒),默认 5 分钟
CAPTCHA_IMAGE_WIDTH=160 # 图片宽度
CAPTCHA_IMAGE_HEIGHT=50 # 图片高度
CAPTCHA_FONT_SIZE=28 # 字体大小
CAPTCHA_FONT_PATH=static/fonts/Vera.ttf # 字体路径(public/ 下)
CAPTCHA_NOISE_LINES=6 # 干扰线数量
CAPTCHA_NOISE_DOTS=300 # 噪点数量
CAPTCHA_RATE_WINDOW=60 # 防刷窗口期(秒)
CAPTCHA_RATE_LIMIT=10 # 窗口期内最大获取次数
CAPTCHA_BYPASS_CODE= # 万能验证码(仅 APP_DEBUG=true 时生效)
```
:::warning 万能验证码安全边界
`CAPTCHA_BYPASS_CODE` 双重保护:**仅 `APP_DEBUG=true` 时生效**,且生产环境必须留空。即使误配,`APP_DEBUG=false` 也会自动失效。
:::
## 安全特性
| 特性 | 说明 | 防御目标 |
|------|------|---------|
| 原子一次性 | Lua GETDEL 取值即删除 | 防止并发重放 |
| 过期机制 | 5 分钟 TTL,超时需重新获取 | 防止长时间尝试破解 |
| 大小写不敏感 | 存储转小写,验证时忽略大小写 | 降低用户输入门槛 |
| 去除易混淆字符 | 字符集去掉 0O、1lI | 减少输入错误 |
| 防刷限流 | 滑动窗口计数,60 秒内最多 10 次/IP | 防止暴力获取验证码 |
| 万能验证码 | 配置 + `app.debug` 双重开关 | 开发调试便利、生产安全 |
| UUID v4 key | 验证码标识不可预测 | 防止枚举攻击 |
| Base64 图片 | 图片以 Base64 返回 | 避免跨域和 URL 泄露 |
| 真实 IP 限流 | `request()->ip()`(ProxyMiddleware 还原) | 防止伪造 IP 头绕过(受信代理校验) |
## 环境配置建议
### 开发环境
```ini
[CAPTCHA]
; 万能验证码,方便调试(登录时输入此码即可跳过验证码)
CAPTCHA_BYPASS_CODE = 888888
; 验证码长度可以短一些
CAPTCHA_LENGTH = 4
# 万能验证码,方便调试(登录时输入此码即可跳过验证码,仅 debug 生效)
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
```
CAPTCHA_BYPASS_CODE= # 必须留空
CAPTCHA_LENGTH=6
CAPTCHA_RATE_WINDOW=60
CAPTCHA_RATE_LIMIT=10
CAPTCHA_NOISE_LINES=6
CAPTCHA_NOISE_DOTS=300
```
## 常见问题
| 现象 | 原因 | 处理 |
|------|------|------|
| 验证码接口 500 | Redis 未连接 / phpredis 扩展缺失 | 见 [2.2 Laragon 环境搭建](/zh/quickstart/laragon.html) Redis 章节 |
| 图片显示空白 | 字体文件缺失 | 确认 `public/static/fonts/Vera.ttf` 存在 |
| 一直提示验证码错误 | 前端传参名错误 | 必须是 `key` / `code`(非 captchaKey/captchaCode) |
| 频繁提示"请求过于频繁" | 同 IP 短时间多次获取 | 等待窗口期结束,或调整 `CAPTCHA_RATE_LIMIT` |
| 万能验证码不生效 | `APP_DEBUG=false` 或未配置 | 仅调试环境可用,生产无法启用 |
+179 -62
View File
@@ -8,14 +8,15 @@
| 特性 | 说明 |
|------|------|
| 基类 | BaseLogic(系统共享,不隔离) |
| Service 基类 | `BaseService`(系统共享,不隔离);读取封装 `ConfigService` |
| 关联 | 与 ConfigItem 一对多(通过 config_id) |
| 用途 | 网站名称、Logo、版权信息等动态配置 |
| 缓存 | DictService 两级缓存(请求级 + Redis),减少数据库查询 |
| 控制器 | `ConfigController`(配置组)+ `ConfigItemController`(配置项) |
| 值处理 | `HasConfigValueProcessing` Trait:按 type 做文件迁移与 `valueList` 生成 |
## 数据库表结构
### 主表 `think_config`(配置组)
### 主表 `laravel_config`(配置组)
| 字段 | 类型 | 说明 |
|------|------|------|
@@ -24,31 +25,25 @@
| 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 自动写入) |
| create_user / create_time / update_user / update_time | | 审计字段(模型 Trait 自动写入) |
| is_delete | int | 软删除标记(0=正常,1=已删除) |
### 子表 `think_config_item`(配置项)
### 子表 `laravel_config_item`(配置项)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 主键,自增 |
| config_id | int | 所属配置组 ID(关联 think_config.id) |
| config_id | int | 所属配置组 ID(关联 laravel_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) |
| value | text | 配置值,按 type 存相对路径、`文件名\|路径`、逗号串或富文本 HTML |
| options | text | 选项串 `k=v,k=v`(逗号分隔),用于 radio / checkbox / select 等类型 |
| type | varchar(150) | 表单类型:hidden / readonly / number / text / textarea / password / radio / checkbox / select / selects / icon / date / datetime / image / images / file / files / ueditor |
| 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=已删除) |
| create_user / create_time / update_user / update_time | | 审计字段 |
| is_delete | int | 软删除标记 |
## API 接口
@@ -58,28 +53,92 @@
|------|------|--------|------|
| GET | `/api/config/page` | sys:config:page | 分页列表 |
| GET | `/api/config/list` | sys:config:list | 全量列表 |
| GET | `/api/config/detail/:id` | sys:config:detail | 详情 |
| 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 | 删除 |
| DELETE | `/api/config/delete/{id}` | sys:config:delete | 删除 |
| DELETE | `/api/config/batchDelete` | sys:config:batchDelete | 批量删除 |
### 网站配置(快捷接口)
| 方法 | 路径 | 权限码 | 说明 |
|------|------|--------|------|
| GET | `/api/config/web/index` | sys:config:web | 获取网站配置(按组返回所有配置项) |
| POST | `/api/config/web/save` | sys:config:web | 保存网站配置(批量更新配置项值) |
| GET | `/api/config/web/index` | sys:config:webIndex | 获取网站配置(按组返回所有配置项) |
| POST | `/api/config/web/save` | sys:config:update | 保存网站配置(批量更新配置项值) |
### 配置项
| 方法 | 路径 | 权限码 | 说明 |
|------|------|--------|------|
| 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 | 删除 |
| GET | `/api/config/item/page` | sys:config:page | 分页列表 |
| GET | `/api/config/item/list` | sys:config:list | 全量列表 |
| GET | `/api/config/item/detail/{id}` | sys:config:detail | 详情 |
| POST | `/api/config/item/add` | sys:config:add | 新增 |
| PUT | `/api/config/item/update` | sys:config:update | 修改 |
| DELETE | `/api/config/item/delete/{id}` | sys:config:delete | 删除 |
| DELETE | `/api/config/item/batchDelete` | sys:config:batchDelete | 批量删除 |
### 配置项详情响应
`GET /api/config/item/detail/{id}`:
- `value` — 数据库存储的原始值;
- `valueList` — 按类型加工后的可展示值,供前端直接使用(不涉及的类型为 `null`);
- `configCode` — 所属配置组编码。
```json
{
"code": 0,
"ok": true,
"msg": "操作成功",
"data": {
"id": 7,
"name": "网站图集",
"code": "website_album",
"value": "config/20260923/110133_AtVRHXAW.png,config/20260923/110133_8ERxsPCx.png,config/20260923/110133_lYnVEf9f.png",
"options": "",
"configId": 1,
"type": "images",
"status": 1,
"sort": 30,
"note": "网站图集",
"createUser": "admin",
"createTime": "2026-09-01 10:37:35",
"updateUser": "admin",
"updateTime": "2026-09-01 10:37:35",
"isDelete": 0,
"configCode": "website_config",
"valueList": [
"http://cdn.example.com/config/20260923/110133_AtVRHXAW.png",
"http://cdn.example.com/config/20260923/110133_8ERxsPCx.png",
"http://cdn.example.com/config/20260923/110133_lYnVEf9f.png"
]
}
}
```
## value 的类型化处理
配置项的 `value` 随 `type` 变化,保存与读取由 `HasConfigValueProcessing` Trait 统一处理,
`ConfigItemService`(配置项 CRUD)与 `ConfigService`(网站配置)共用。
文件一律落在 `config/file.php` 的 `upload_dir` 下的 `config/` 子目录。
| type | 入库 value | 详情 value | 详情 valueList |
|------|-----------|-----------|----------------|
| `image` | `config/Ymd/xxx.png`(去域名 + 临时文件迁移) | 完整 URL | `null` |
| `images` | 逗号分隔的相对路径 | 原值 | 逐个完整 URL |
| `file` / `files` | `文件名\|相对路径`(逗号分隔) | 原值 | `文件名\|完整URL` |
| `checkbox` | 逗号分隔原值 | 原值 | 拆分后的数组 |
| `ueditor` | 富文本 HTML,内嵌图片迁移为 `[IMG_URL]/config/...` 占位 | 占位还原为当前 `FILE_DOMAIN_URL` | `null` |
| 其余类型 | 原值 | 原值 | `null` |
处理规则:
- **保存**(`beforeAdd` / `beforeUpdate`):走 `save_file()` —— 去掉域名前缀、把 `temp/` 临时文件迁移到正式目录;
富文本走 `save_content()` —— 迁移内容中的图片并写入 `[IMG_URL]` 占位;
- **读取**:走 `get_file_url()` 补全域名;富文本把 `[IMG_URL]` 还原为 `FILE_DOMAIN_URL`;
- 迁移失败时保留原值,不清空字段;
- `image` 与 `ueditor` 的 `value` 直接返回可渲染值(前端回显直连 `value`),其余类型 `value` 保持原值。
## 使用场景
@@ -96,63 +155,119 @@
### 后端获取配置值
项目中没有 `ConfigService`,配置值通过 `ConfigItem` 模型直接查询:
配置值可通过 `ConfigService`(网站配置快捷接口的封装)或 `ConfigItem` 模型直接查询:
```php
<?php
use app\model\ConfigItem;
use app\model\Config;
use App\Models\ConfigItem;
use App\Models\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)->first();
$value = $config
? ConfigItem::where('config_id', $config->id)
->where('code', 'site_name')
->where('status', 1)
->where('is_delete', 0)
->value('value')
: null;
// 返回:'My CMS'
// 获取整个配置组的所有配置项(关联数组)
$config = Config::where('code', 'website_config')->where('is_delete', 0)->find();
$config = Config::where('code', 'website_config')->where('is_delete', 0)->first();
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' => '...']
->pluck('value', 'code');
// 返回:['site_name' => 'My CMS', 'site_logo' => '...', 'copyright' => '...']
}
```
// 通过 API 获取网站配置(推荐,含类型处理和 URL 拼接)
// GET /api/config/web/index → 返回按配置组组织的完整数据
### 网站配置快捷服务
```php
// app/Services/ConfigService.php
public function getWebConfig(): array // GET /api/config/web/index:按组返回 configId/configName/dataList(含 param、valueList)
public function saveWebConfig(array $data): void // POST /api/config/web/save:按编码批量回写,经 save_file / save_content 归一化
```
### 前端获取配置值
`GET /api/config/web/index` 返回按配置组分组的配置项数组:
```json
[
{
"configId": 1,
"configName": "网站配置",
"dataList": [
{
"id": 3, "configId": 1, "name": "网站Logo", "code": "website_logo",
"value": "http://cdn.example.com/config/20260923/110055_w3KY8kS7.png",
"valueList": null, "options": "", "type": "image",
"status": 1, "sort": 10, "note": "网站Logo", "param": null
},
{
"id": 7, "code": "website_album", "type": "images",
"value": "config/20260923/110133_AtVRHXAW.png,config/20260923/110133_8ERxsPCx.png",
"valueList": [
"http://cdn.example.com/config/20260923/110133_AtVRHXAW.png",
"http://cdn.example.com/config/20260923/110133_8ERxsPCx.png"
],
"param": null
},
{
"id": 11, "code": "website_keywords", "type": "checkbox",
"value": "1,2,5,3",
"valueList": ["1", "2", "5", "3"],
"options": "1=软件架构,2=官方网站",
"param": { "1": "软件架构", "2": "官方网站" }
}
]
}
]
```
取值约定:
- `image` 与 `ueditor` 读 `value`(返回可直接渲染的完整 URL / 富文本 HTML);
- `images` / `files` 读 `valueList`(完整 URL 串、`文件名|完整URL` 串);
- `radio` / `checkbox` / `select` 的候选项读 `param`(由 `options` 解析成键值对象);
- 其余类型的 `value` 为数据库原值,拼接 `FILE_DOMAIN_URL` 即可访问。
```typescript
// 获取网站配置
// 获取网站配置(按配置组分组)
const { data } = await http.get('/api/config/web/index')
// 使用配置值
const siteName = data.site_name
const siteLogo = data.site_logo
data.forEach((group) => {
group.dataList.forEach((item) => {
// image / ueditor 取 value,images / files 取 valueList,选项类取 param
})
})
```
### 网站配置保存接口
`POST /api/config/web/save` 的请求体为「配置项编码 => 配置值」的扁平结构,
只更新已存在的生效配置项,编码不存在时忽略;数组值(多图 / 多文件 / 复选)按逗号拼接后入库,
文件与图片同样迁移到 `config/` 子目录。
```bash
# -----------------------------------------------------------------------------
# 批量保存网站配置(按配置组 code 批量更新)
# 批量保存网站配置(key 为配置项编码)
# -----------------------------------------------------------------------------
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"
}
"website_name": "My CMS",
"website_logo": "http://cdn.example.com/temp/20261007/123456_a1b2c3d4.png",
"website_album": [
"http://cdn.example.com/temp/20261007/123456_a1b2c3d4.png",
"http://cdn.example.com/temp/20261007/123456_e5f6a7b8.png"
],
"website_keywords": ["1", "2"]
}'
```
@@ -163,30 +278,32 @@ curl -X POST http://localhost:8000/api/config/web/save \
| 数据结构 | 分组 + 配置项(一对多) | 独立参数实体 |
| 适用场景 | 关联性强的配置(如网站配置包含多个字段) | 独立的开关、阈值 |
| 管理界面 | 按组展示,表单式编辑 | 列表式管理 |
| 获取方式 | `ConfigItem` 模型查询或 `/api/config/web/index` 接口 | `ParamService::getValue(code)` |
| 缓存策略 | 两级缓存(请求级 + Redis) | 两级缓存(请求级 + Redis) |
| 获取方式 | `ConfigItem` 模型查询或 `/api/config/web/index` 接口 | `ParamService::get(code)` |
| 服务封装 | `ConfigService`(webIndex/webSave) | `ParamService`(带缓存) |
## 数据库操作示例
```sql
-- 查询所有未删除的配置组
SELECT * FROM think_config WHERE is_delete = 0 ORDER BY sort;
SELECT * FROM laravel_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
SELECT ci.* FROM laravel_config_item ci
INNER JOIN laravel_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 = '新网站名称'
UPDATE laravel_config_item SET value = '新网站名称'
WHERE code = 'site_name' AND config_id = (
SELECT id FROM think_config WHERE code = 'website_config' AND is_delete = 0
SELECT id FROM laravel_config WHERE code = 'website_config' AND is_delete = 0
);
```
## 相关文档
- [5.12 系统参数管理](/zh/core/param.html) — 独立参数实体管理
- [5.13 系统参数管理](/zh/core/param.html) — 独立参数实体管理
- [5.10 字典模块](/zh/core/dict.html) — 枚举/字典数据管理
- [5.11 系统配置管理](/zh/core/config.html) — 本文档
- [7.1 文件上传全流程](/zh/tools/upload.html) — 配置项图片/附件的上传与临时文件迁移
- [7.3 富文本处理](/zh/tools/rich-text.html) — `[IMG_URL]` 占位符机制
- [附录 C 配置项速查](/zh/appendix/config-reference.html) — `config/*.php` 配置文件速查(与本模块的"动态配置"不同,后者存数据库)
+133 -119
View File
@@ -1,7 +1,7 @@
# 5.5 跨域资源共享(CORS)
# 5.5 跨域资源共享
:::tip 概述
CorsMiddleware 处理跨域请求,支持通过 `.env` 动态配置允许的来源域名。配置使用逗号分隔的字符串,中间件将其 `explode` 为数组后做白名单匹配。开发环境可设为 `*` 允许所有来源,生产环境应指定具体域名。
`Cors` 中间件处理跨域请求,支持通过 `.env` 的 `CORS_ALLOW_ORIGIN` 动态配置允许的来源域名(逗号分隔字符串,配置时 `explode` 为数组做白名单匹配)。凭证模式下自动回显具体 Origin 并设置 `Vary: Origin`;开发环境可设为 `*`,生产环境应指定具体域名。
:::
## CORS 工作原理
@@ -41,7 +41,7 @@ CorsMiddleware 处理跨域请求,支持通过 `.env` 动态配置允许的来
→ 服务端返回 CORS 头,浏览器判断是否允许
预检请求(Preflight Request):
不满足简单请求条件时(如 PUT/DELETE、JSON 请求体、自定义头):
不满足简单请求条件时(如 PUT/DELETE、JSON 请求体、Authorization 头):
→ 浏览器先发送 OPTIONS 请求询问服务端
→ 服务端返回允许的方法和头
→ 浏览器再发送实际请求
@@ -62,10 +62,11 @@ CorsMiddleware 处理跨域请求,支持通过 `.env` 动态配置允许的来
│ Content-Type, Authorization │
│ │
│ ┌──────────┴──────────┐
│ │ CorsMiddleware │
│ │ 1. 检查 Origin 白名单│
│ │ 2. 返回 CORS 头 │
│ │ 3. 返回 204 空响应 │
│ │ Cors 中间件 │
│ │ 1. 检查 Origin 白名单 │
│ │ 2. 返回 CORS 头 │
│ │ 3. 返回 204 空响应 │
│ │ (不进业务逻辑) │
│ └──────────┬──────────┘
│ │
│ ◄── 2. 预检响应 ─────────────────────│
@@ -92,6 +93,7 @@ CorsMiddleware 处理跨域请求,支持通过 `.env` 动态配置允许的来
│ 200 OK │
│ Access-Control-Allow-Origin: │
│ https://admin.example.com │
│ Vary: Origin │
│ { "code": 0, "data": {...} } │
│ │
```
@@ -101,97 +103,106 @@ CorsMiddleware 处理跨域请求,支持通过 `.env` 动态配置允许的来
### 环境变量
```ini
; .env
# .env
; 开发环境:允许所有来源
[CORS]
ALLOW_ORIGIN = *
# 开发环境:允许所有来源
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
# 生产环境:指定具体域名(多个用逗号分隔)
CORS_ALLOW_ORIGIN=https://admin.example.com,https://www.example.com
```
### 配置文件
```php
// config/cors.php
return [
// 允许的来源域名,逗号分隔,* 表示允许所有
// 注意:配置含 * 时中间件会回显请求的 Origin,而非直接返回通配符,
// 以兼容携带凭证时浏览器不允许 Allow-Origin 为 * 的限制
'allow_origin' => explode(',', env('cors.allow_origin', '*')),
// config/cors.php(节选)
// 允许的请求方法
'allow_methods' => 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
// 允许的来源域名,逗号分隔,* 表示允许所有
// 中间件会做 trim + 空值过滤;含 * 且开启凭证时回显请求 Origin,
// 以兼容携带凭证时浏览器不允许 Allow-Origin 为 * 的限制
'allow_origin' => array_filter(array_map('trim', explode(',', env('CORS_ALLOW_ORIGIN', '')))),
// 允许的请求头
'allow_headers' => 'Content-Type, Authorization, X-Requested-With, Accept, Origin, Token',
// 允许的请求方法
'allow_methods' => 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
// 暴露给前端的响应头,便于前端读取
'expose_headers' => 'Authorization',
// 允许的请求头
'allow_headers' => 'Content-Type, Authorization, X-Requested-With, Accept, Origin, Token',
// 预检请求(OPTIONS)结果的缓存时间,单位秒
'max_age' => 3600,
// 暴露给前端的响应头,便于前端读取
'expose_headers' => 'Authorization',
// 是否允许携带凭证(Cookie 等)
'allow_credentials' => true,
];
// 预检请求(OPTIONS)结果的缓存时间,单位秒
'max_age' => 3600,
// 是否允许携带凭证(Cookie 等)
'allow_credentials' => true,
```
:::warning 默认值为空白名单
`CORS_ALLOW_ORIGIN` 未配置时允许来源为**空数组**(拒绝一切跨域)。`.env.example` 默认给了 `*`(开发便利),生产环境必须改为具体域名。
:::
### 配置项说明
| 配置项 | 说明 | 默认值 | 生产建议 |
|--------|------|--------|----------|
| `allow_origin` | 允许的来源域名 | `*` | 指定具体域名 |
| `allow_origin` | 允许的来源域名 | 空(`.env` 示例为 `*`) | 指定具体域名 |
| `allow_methods` | 允许的 HTTP 方法 | `GET, POST, PUT, DELETE, PATCH, OPTIONS` | 按需精简 |
| `allow_headers` | 允许的请求头 | 见配置文件 | 按需精简 |
| `expose_headers` | 暴露给前端的响应头 | `Authorization` | 按需添加 |
| `max_age` | 预检缓存时间(秒) | `3600` | 可适当增大 |
| `allow_credentials` | 是否允许携带凭证 | `true` | 前端使用 Cookie 时必须为 true |
| `allow_credentials` | 是否允许携带凭证 | `true` | 前端使用 Cookie/凭证时必须为 true |
## 中间件代码
```php
// app/middleware/CorsMiddleware.php
// app/Http/Middleware/Cors.php(节选)
public function handle(Request $request, \Closure $next): Response
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 ?: '*';
// 预检请求直接返回,不进入业务逻辑(Authenticate 等中间件不会执行)
if ($request->isMethod('OPTIONS')) {
$response = response('', 204);
} else {
// 仅在白名单内才回显 Origin,不在白名单则置空
$allowOrigin = in_array($origin, $allowed) ? $origin : '';
$response = $next($request);
}
$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'],
];
$config = config('cors');
$origin = $request->header('Origin', '');
$allowOrigins = $config['allow_origin'] ?? ['*'];
$allowCredentials = $config['allow_credentials'] ?? true;
// OPTIONS 预检请求直接返回 204
if ($request->isOptions()) {
return response('', 204, $header);
$originAllowed = false;
if (in_array('*', $allowOrigins)) {
if ($allowCredentials && $origin) {
// 凭证模式下不能用 *,回显具体 Origin
$response->headers->set('Access-Control-Allow-Origin', $origin);
// 回显具体 Origin 时必须设置 Vary: Origin,
// 告知 CDN/代理该响应随 Origin 变化,避免缓存错误内容
$response->headers->set('Vary', 'Origin');
} else {
$response->headers->set('Access-Control-Allow-Origin', '*');
}
$originAllowed = true;
} elseif ($origin && in_array($origin, $allowOrigins)) {
// 白名单匹配:仅回显请求的 Origin
$response->headers->set('Access-Control-Allow-Origin', $origin);
$response->headers->set('Vary', 'Origin');
$originAllowed = true;
}
$response = $next($request);
// 仅在 Origin 允许时设置其余 CORS 头(非法来源不暴露能力)
if ($originAllowed) {
$response->headers->set('Access-Control-Allow-Methods', $config['allow_methods'] ?? 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
$response->headers->set('Access-Control-Allow-Headers', $config['allow_headers'] ?? 'Content-Type, Authorization');
$response->headers->set('Access-Control-Expose-Headers', $config['expose_headers'] ?? 'Authorization');
$response->headers->set('Access-Control-Max-Age', $config['max_age'] ?? 3600);
// 添加跨域头
$response->header($header);
if ($allowCredentials) {
$response->headers->set('Access-Control-Allow-Credentials', 'true');
}
}
return $response;
}
@@ -201,7 +212,7 @@ public function handle(Request $request, \Closure $next): Response
### Origin 白名单匹配
配置通过 `explode(',', env(...))` 将逗号分隔的字符串转为数组,中间件使用 `in_array()` 进行精确匹配:
配置通过 `explode(',', env(...))` 将逗号分隔的字符串转为数组(带 `trim` 与空值过滤),中间件使用 `in_array()` 精确匹配:
```text
请求 Origin: https://admin.example.com
@@ -210,21 +221,22 @@ public function handle(Request $request, \Closure $next): Response
请求 Origin: https://evil.com
白名单数组: ['https://admin.example.com', 'https://www.example.com']
→ in_array() 匹配失败,$allowOrigin = '' ✗
→ 浏览器收到空的 Allow-Origin,阻止前端读取响应
→ in_array() 匹配失败 → 不设置任何 CORS 头 ✗
→ 浏览器读取不到 Allow-Origin,阻止前端读取响应
```
### 通配符 * 的特殊处理
### 通配符 `*` 的特殊处理
当配置为 `*` 时,`explode(',', '*')` 返回 `['*']`。中间件检测到 `in_array('*', $allowed)` 后,会回显请求的 Origin 而非直接返回 `*`,这是为了兼容浏览器在携带凭证(Cookie)时不允许 `Access-Control-Allow-Origin: *` 的限制。
当配置为 `*` 且开启凭证时,中间件**回显请求的 Origin** 而非直接返回 `*`:
```text
配置: ALLOW_ORIGIN = *
配置: CORS_ALLOW_ORIGIN=*
请求 Origin: https://admin.example.com
中间件行为:
in_array('*', ['*']) → true
$allowOrigin = $origin(回显 'https://admin.example.com')
且 allow_credentials=true 且有 Origin
→ 回显 'https://admin.example.com' + Vary: Origin
响应头:
Access-Control-Allow-Origin: https://admin.example.com ✓
@@ -236,9 +248,11 @@ public function handle(Request $request, \Closure $next): Response
→ 浏览器报错:Allow-Origin 为 * 时不能携带凭证
```
`Vary: Origin` 同时防止 CDN/代理把 A 站的 CORS 响应缓存给 B 站。
### OPTIONS 预检请求
浏览器在跨域请求前会先发送 OPTIONS 预检请求,中间件通过 `$request->isOptions()` 判断后,直接返回 `response('', 204, $header)` 空响应,避免进入业务逻辑。
浏览器在跨域请求前会先发送 OPTIONS 预检请求,中间件判断后直接返回 `response('', 204)` 空响应,**不进入后续中间件与控制器**(预检不携带业务凭证,这是 CORS 规范的预期行为):
```text
OPTIONS /api/user/update HTTP/1.1
@@ -247,69 +261,69 @@ Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization
→ 中间件拦截,返回 204 + CORS 头
→ 不进入 AuthMiddleware / Controller
→ 不进入 Authenticate / ResolveTenant / 控制器
```
## 与其他中间件的协作
```text
请求进入
请求进入(bootstrap/app.php 的全局 api 组顺序)
│
▼
CorsMiddleware(全局,第一个执行)
ProxyMiddleware ← 先还原真实 IP(限流/日志依赖)
│
▼
Cors ← 第二个执行
│ OPTIONS → 直接返回 204 + CORS 头
│ 普通请求 → 设置 CORS 头,继续
│ 普通请求 → 继续(响应时追加 CORS 头)
▼
AuthMiddleware
│ 校验 JWT + 权限
ForceJsonResponse
│
▼
TenantMiddleware
│ 注入租户上下文
Authenticate(auth.jwt) ← 校验 JWT + 权限(仅路由组)
▼
ResolveTenant / LogOperation
▼
Controller
```
:::tip 执行顺序
CorsMiddleware 必须是第一个执行的中间件,确保 OPTIONS 预检请求在认证之前就被处理,否则预检请求会因缺少 Token 而被 AuthMiddleware 拒绝。
`Cors` 必须位于 `Authenticate` 之前(全局 api 组 prepend 保证),确保 OPTIONS 预检在认证之前就被处理,否则预检请求会因缺少 Token 而被拒绝。同时**只有 Origin 被允许时**才设置其余 CORS 头,避免对非法来源暴露接口能力。
:::
## 常见问题排查
### 问题 1:No 'Access-Control-Allow-Origin' header
**原因:** 请求的 Origin 不在白名单中。
**原因:** 请求的 Origin 不在白名单中(或 `CORS_ALLOW_ORIGIN` 为空)。
**解决:**
```ini
# 检查 .env 中的 ALLOW_ORIGIN 是否包含前端的域名
[CORS]
ALLOW_ORIGIN = https://admin.example.com
# 检查 .env 中的 CORS_ALLOW_ORIGIN 是否包含前端的域名
CORS_ALLOW_ORIGIN=http://localhost:8001
```
### 问题 2:Allow-Origin 为 * 时携带凭证失败
改后执行 `php artisan config:clear` 并重启服务。
**原因:** 浏览器规定 `Access-Control-Allow-Origin: *` 时不能携带 Cookie。
### 问题 2:Allow-Origin 为 `*` 时携带凭证失败
**解决:** 中间件已自动处理——配置为 `*` 时回显请求 Origin 而非返回通配符。如果仍报错,检查前端是否设置了 `withCredentials: true`。
**原因:** 浏览器规定 `Access-Control-Allow-Origin: *` 时不能携带凭证。
**解决:** 中间件已自动处理——凭证模式下回显请求 Origin 而非返回通配符。如仍报错,检查前端是否设置了 `withCredentials: true` 而后端 `allow_credentials=false`。
### 问题 3:预检请求返回 401/403
**原因:** OPTIONS 请求被 AuthMiddleware 拦截。
**原因:** OPTIONS 请求被 `Authenticate` 拦截。
**解决:** 确保 CorsMiddleware 在 AuthMiddleware 之前执行。检查中间件注册顺序:
```php
// app/middleware.php(全局中间件)
return [
\app\middleware\CorsMiddleware::class, // 必须在最前面
];
```
**解决:** 确认中间件注册顺序(`bootstrap/app.php` 中 `Cors` 在 `auth.jwt` 之前)。当前全局 api 组为 `Proxy → Cors → ForceJson`,认证在路由组——顺序天然正确;如自行调整注册需复查。
### 问题 4:自定义请求头被拒绝
**原因:** 请求头不在 `allow_headers` 列表中。
**解决:**
```php
// config/cors.php
'allow_headers' => 'Content-Type, Authorization, X-Requested-With, Accept, Origin, Token, X-Custom-Header',
@@ -320,39 +334,38 @@ return [
**原因:** 响应头不在 `expose_headers` 列表中。
**解决:**
```php
// config/cors.php
'expose_headers' => 'Authorization, X-Total-Count, X-Page-Count',
```
### 问题 6:改了配置不生效
`.env` / `config/cors.php` 修改后需清除配置缓存:
```bash
php artisan config:clear
# 生产环境如执行过 config:cache,需重新缓存
php artisan config:cache
```
## 环境配置建议
### 开发环境
```ini
[CORS]
ALLOW_ORIGIN = *
# 开发环境:允许所有来源(前端 localhost:8001,后端 localhost:8000)
CORS_ALLOW_ORIGIN=*
# 测试环境:指定测试域名,提前验证跨域配置
CORS_ALLOW_ORIGIN=https://test.admin.example.com
# 生产环境:只允许正式域名,不使用 *
CORS_ALLOW_ORIGIN=https://admin.example.com,https://www.example.com
```
允许所有来源,方便前后端分离开发(前端 `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
```
只允许正式域名,不使用 `*`。
:::tip Vite 代理可绕过跨域
开发环境前端通过 Vite `proxy` 把 `/api` 转发到后端(同源),此时不产生跨域、不依赖 CORS 配置。直连后端(`VITE_GLOB_API_URL` 配置完整地址)时才依赖本配置。见 [2.4 前端服务启动](/zh/quickstart/frontend-start.html)。
:::
## CORS 响应头速查
@@ -364,3 +377,4 @@ ALLOW_ORIGIN = https://admin.example.com,https://www.example.com
| `Access-Control-Expose-Headers` | 暴露的响应头 | `Authorization` |
| `Access-Control-Max-Age` | 预检缓存时间 | `3600` |
| `Access-Control-Allow-Credentials` | 是否允许凭证 | `true` |
| `Vary` | 缓存维度提示 | `Origin` |
@@ -1,7 +1,7 @@
# 5.9 数据权限
:::tip 概述
数据权限控制用户能看到哪些数据,基于角色的 `data_scope` 字段进行过滤。支持三种范围:全部数据、本部门数据、仅本人数据。
数据权限控制用户能看到哪些数据,由 `HasDataScope` Trait 的 `applyDataScope()` 在查询构建时按角色数据范围自动叠加过滤条件,支持三种范围:全部数据、本部门数据、仅本人数据。当前 `laravel_role` 表不含 `data_scope` 字段——`getRoleDataScope()` 读取缺省为 1,所有角色按全部数据处理。
:::
## 三种范围
@@ -27,7 +27,7 @@
┌─────────────────────────────────────────────────────────┐
│ data_scope = 3 │
│ 仅本人数据 │
│ 仅本人数据(无角色用户默认) │
│ │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │ A │ │ B │ │███│ │ D │ │ E │ ← 只看到自己创建的数据 │
@@ -37,23 +37,25 @@
## 配置方式
在 Logic 中配置数据权限字段即可启用:
:::warning 前置条件
数据权限能力由 `HasDataScope` Trait 提供——继承 `BaseTenantService` 的服务已自动 `use` 该 Trait;直接继承 `BaseService` 的服务需自行 `use Concerns\HasDataScope`。且 **`BaseTenantService` 默认未启用数据权限**,子类必须声明字段才会生效。
:::
```php
class UserLogic extends BaseTenantLogic
class UserService extends BaseTenantService
{
/**
* 数据权限:部门关联字段名(用于按部门过滤)
* 数据权限:部门关联字段名(用于按部门过滤,data_scope=2 时生效)
*
* @var string
*/
protected string $dataScopeDeptField = 'dept_id';
}
class ArticleLogic extends BaseTenantLogic
class ArticleService extends BaseTenantService
{
/**
* 数据权限:归属字段名(用于按创建人过滤)
* 数据权限:归属字段名(用于按创建人过滤,data_scope=3 时生效)
*
* @var string
*/
@@ -61,72 +63,95 @@ class ArticleLogic extends BaseTenantLogic
}
```
| 属性 | 生效场景 | 过滤条件 |
|------|---------|---------|
| `dataScopeDeptField` | `data_scope=2` 本部门 | `WHERE {字段} = 当前用户部门ID` |
| `dataScopeUserField` | `data_scope=3` 仅本人 | `WHERE {字段} = 当前用户名` |
## 实现机制
```php
// app/BaseLogic.php
// app/Services/Concerns/HasDataScope.php(行为说明)
protected function applyDataScope($model)
protected function applyDataScope($query)
{
// 未配置数据权限字段则跳过
if (empty($this->dataScopeUserField) && empty($this->dataScopeDeptField)) {
return $model;
return $query;
}
// 获取当前用户信息
$userInfo = request()->userInfo ?? null;
$uid = $userInfo->uid ?? 0;
$roleId = $userInfo->role_id ?? 0;
// 获取角色的数据权限范围
$dataScope = $this->getRoleDataScope($roleId);
// 1=全部数据,不做过滤
if ($dataScope == 1) {
return $model;
return $query;
}
// 2=本部门数据
// 2=本部门数据(配置了 dataScopeDeptField 时)
if ($dataScope == 2 && $this->dataScopeDeptField) {
$deptId = $this->getUserDeptId($uid);
if ($deptId) {
$model = $model->where($this->dataScopeDeptField, $deptId);
$query = $query->where($this->dataScopeDeptField, $deptId);
}
return $model;
return $query;
}
// 3=仅本人数据
// 3=仅本人数据(配置了 dataScopeUserField 时)
if ($dataScope == 3 && $this->dataScopeUserField) {
$model = $model->where($this->dataScopeUserField, $userInfo->username);
return $model;
return $query->where($this->dataScopeUserField, $username);
}
return $model;
return $query;
}
```
## 角色配置
**查询链中的位置**(`HasQueryScopes::buildQuery`):
在角色管理中设置 `data_scope` 字段:
```text
额外固定条件($where)
→ LIKE 模糊匹配(pageLikeFields)
→ 精确匹配(pageEqFields)
→ 数据权限过滤(applyDataScope) ← 本节
→ 租户隔离过滤(applyTenantScope)
```
:::warning 无角色用户
无角色时默认按"仅本人"(scope=3)处理——宁可少看,不可多看。
:::
## 数据范围语义
`getRoleDataScope()` 按以下范围解析(`HasDataScope` 内部实现):
| 值 | 范围 | 适用场景 |
|----|------|----------|
| 1 | 全部数据 | 管理层、超级管理员 |
| 1 | 全部数据 | 管理层、超级管理员(当前默认) |
| 2 | 本部门数据 | 部门经理 |
| 3 | 仅本人数据 | 普通员工 |
| 3 | 仅本人数据 | 普通员工(无角色用户默认) |
## 缓存说明
角色的数据权限范围带 1 小时缓存(`role_data_scope_{roleId}`)。修改角色权限后需等待缓存过期或手动清除。
:::info 当前版本现状
`laravel_role` 表不包含 `data_scope` 字段(按当前表设计),角色一律解析为 1(全部数据);上表为 `HasDataScope` 的完整语义。服务只需声明 `dataScopeUserField` / `dataScopeDeptField` 字段,范围解析逻辑保持不变。
:::
## 超级管理员
`uid=1` 的超级管理员不受数据权限限制,始终看到全部数据。详见 [RBAC 权限管理](/zh/core/rbac.html)。
`uid=1` 的超级管理员不受数据权限限制(权限与租户校验均跳过),始终看到全部数据。详见 [5.4 权限管理(RBAC)](/zh/core/rbac.html)。
## 与租户隔离的关系
数据权限和租户隔离是两个独立的维度:
- **租户隔离**:按 `tenant_id` 过滤,不同租户数据完全隔离
- **数据权限**:在同一租户内,按部门或创建人进一步过滤
两者可以同时生效,租户隔离优先级更高。
- **租户隔离**:按 `tenant_id` 过滤,不同租户数据完全隔离(`BaseTenantService` 自动启用)
- **数据权限**:在同一租户内,按部门或创建人进一步过滤(需声明字段启用)
两者可以同时生效,租户隔离优先级更高(先租户、后数据权限)。详见 [5.8 多租户隔离](/zh/core/tenant.html)。
## 常见问题
| 现象 | 原因 | 处理 |
|------|------|------|
| 配置了字段但过滤不生效 | 服务未启用 `HasDataScope` | 继承 `BaseTenantService` 或手动 `use` Trait |
| 普通用户看到全部数据 | 当前角色数据范围解析为 1(表无 `data_scope` 字段) | 属于当前版本现状,见上文「数据范围语义」 |
| 部门过滤失效(scope=2) | 未配置 `dataScopeDeptField` 或用户无部门 | 声明字段并给用户分配部门 |
| 仅本人过滤失效(scope=3) | 未配置 `dataScopeUserField` | 声明字段(如 `create_user`) |
+168 -108
View File
@@ -1,7 +1,7 @@
# 5.7 演示模式
:::tip 概述
演示模式是系统内置的安全机制,用于公共演示环境的数据保护。开启后,DemoMiddleware 自动拦截所有写操作(POST/PUT/DELETE/PATCH),仅允许读操作(GET)通过,防止演示环境被随意修改。通过 `#[DemoAllow]` 注解可豁免特定接口。
演示模式是系统内置的安全机制,用于公共演示环境的数据保护。开启后,`DemoMiddleware` 自动拦截所有写操作(POST/PUT/DELETE/PATCH),仅允许读操作(GET)通过,防止演示环境被随意修改。通过 `#[DemoAllow]` 注解可豁免特定接口。
:::
## 应用场景
@@ -24,17 +24,60 @@
```ini
# .env
[APP]
# 开启演示模式(true=开启,false=关闭)
DEMO = true
DEMO_MODE=true
```
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `DEMO` | `false` | 演示模式开关,`true` 开启拦截,`false` 正常运行 |
| 变量 | 默认值 | 读取位置 | 说明 |
|------|--------|---------|------|
| `DEMO_MODE` | `false` | `config('app.demo_mode')` → DemoMiddleware | 演示模式开关,`true` 开启拦截,`false` 正常运行 |
:::warning 生产环境务必关闭
演示模式会拦截所有写操作,生产环境必须设为 `false`,否则用户无法执行任何增删改操作。
演示模式会拦截所有写操作,生产环境必须保持 `DEMO_MODE=false`,否则用户无法执行任何增删改操作。
:::
## 启用与验证
演示模式生效需**同时满足两个条件**,缺一不可:
1. **配置接入**:`config/app.php` 定义 `demo_mode` 键(读取 `DEMO_MODE` 环境变量);
2. **中间件挂载**:`demo` 中间件已挂载到目标路由组(见[中间件挂载与执行顺序](#中间件挂载与执行顺序))。
启用步骤:
```bash
# ① .env 开启
DEMO_MODE=true
# ② 清除配置缓存(如执行过 config:cache,改完需重新缓存)
php artisan config:clear
```
逐项验证:
```bash
# ① 配置键已接入(期望输出 true)
php artisan tinker --execute="dump(config('app.demo_mode'))"
# ② 中间件已挂载到认证路由组(demo 位于 tenant 与 log.operation 之间)
php artisan route:list --path=user/add --json
# → "middleware": ["api", "auth.jwt", "tenant", "demo", "log.operation"]
# ③ 拦截生效(携带有效 token 发起写操作,期望 HTTP 403)
curl -X POST http://localhost:8000/api/user/add \
-H "Authorization: Bearer {access_token}" \
-H "Accept: application/json" \
-d '{"username":"demo"}'
# → {"code":403,"ok":false,"msg":"演示模式下禁止操作","data":null}
```
:::warning 两层缺一不可
仅设置 `DEMO_MODE=true` 不代表生效:
- 若 `config/app.php` 缺少 `demo_mode` 键,`config('app.demo_mode')` 永远返回默认值 `false`,中间件直接放行;
- 若 `demo` 中间件未挂载到路由组,`DemoMiddleware::handle()` 根本不会执行。
启用后请按上述清单逐项验证,不要只改 `.env`。
:::
## 拦截规则
@@ -43,25 +86,28 @@ DEMO = true
请求进入
│
▼
┌──────────────────────────┐
│ ① DEMO = false ? │──── 是 ──→ 直接放行(非演示环境)
└──────────┬───────────────┘
┌──────────────────────────────┐
│ ① demo_mode = false ? │──── 是 ──→ 直接放行(非演示环境)
└──────────┬───────────────────┘
│ 否
▼
┌──────────────────────────┐
│ ② 请求方法 = GET ? │──── 是 ──→ 直接放行(读操作)
└──────────┬───────────────┘
┌──────────────────────────────┐
│ ② 请求方法 = GET ? │──── 是 ──→ 直接放行(读操作)
└──────────┬───────────────────┘
│ 否(POST/PUT/DELETE/PATCH)
▼
┌──────────────────────────┐
│ ③ 标注 #[DemoAllow] ? │──── 是 ──→ 直接放行(白名单豁免)
└──────────┬───────────────┘
┌──────────────────────────────┐
│ ③ 标注 #[DemoAllow] ? │──── 是 ──→ 直接放行(白名单豁免)
│ (ReflectionMethod 反射读取, │
│ 反射异常静默视为未标注) │
└──────────┬───────────────────┘
│ 否
▼
┌──────────────────────────┐
│ 返回 403 │
│ "演示环境,禁止操作" │
└──────────────────────────┘
┌──────────────────────────────┐
│ HTTP 403 │
│ {"code":403, │
│ "msg":"演示模式下禁止操作"} │
└──────────────────────────────┘
```
| 请求方法 | 行为 | 说明 |
@@ -73,24 +119,45 @@ DEMO = true
| PATCH | ❌ 拦截 | 部分修改操作 |
| 任意方法 + `#[DemoAllow]` | ✅ 放行 | 白名单豁免 |
## 中间件执行位置
## 中间件挂载与执行顺序
DemoMiddleware 位于需认证路由组的中间件链中,执行顺序:
`DemoMiddleware` 以别名 `demo` 注册于 `bootstrap/app.php`,并挂载在认证路由组上(`routes/api.php`):
```php
// bootstrap/app.php —— 别名注册
$middleware->alias([
// ...
'demo' => DemoMiddleware::class,
]);
// routes/api.php —— 认证路由组
// demo 位于 tenant 之后、log.operation 之前
Route::middleware(['auth.jwt', 'tenant', 'demo', 'log.operation'])
->group(function () { /* ... */ });
```
```text
CorsMiddleware(全局)
→ AuthMiddleware(认证 + 权限)
→ TenantMiddleware(租户上下文)
→ DemoMiddleware(演示模式拦截) ← 本中间件
→ LogMiddleware(操作日志)
→ 控制器
挂载后的执行顺序(认证路由组内):
ProxyMiddleware(全局)
→ Cors(全局)
→ ForceJsonResponse(全局)
→ Authenticate(认证 + 权限)
→ ResolveTenant(租户上下文)
→ DemoMiddleware(演示模式拦截) ← 本中间件
→ LogOperation(操作日志)
→ 控制器
```
:::tip 执行位置说明
DemoMiddleware 在 AuthMiddleware 和 TenantMiddleware 之后执行,意味着:
- 未登录用户已被 AuthMiddleware 拦截(401),不会到达演示模式判断
- 租户状态已由 TenantMiddleware 校验,确保用户合法
- 演示模式仅拦截"合法用户的写操作",不影响认证流程
DemoMiddleware 设计在 `Authenticate` 和 `ResolveTenant` 之后执行,意味着:
- 未登录用户已被 `Authenticate` 拦截(401),不会到达演示模式判断;
- 租户状态已由 `ResolveTenant` 校验,确保用户合法;
- **位于 `LogOperation` 之前**:被演示模式拦截(403)的请求不再写入操作日志;
- 演示模式仅拦截"合法用户的写操作",不影响认证流程。
登录、验证码、令牌刷新等公开接口定义在路由组外,不经过 `demo` 中间件。
:::
## 核心代码
@@ -98,42 +165,20 @@ DemoMiddleware 在 AuthMiddleware 和 TenantMiddleware 之后执行,意味着
### DemoMiddleware
```php
// app/middleware/DemoMiddleware.php
// app/Http/Middleware/DemoMiddleware.php(节选)
class DemoMiddleware
{
public function handle(Request $request, \Closure $next): Response
public function handle(Request $request, Closure $next): Response
{
// ① 非演示环境,直接放行
if (!env('app_demo', false)) {
// ① 非演示模式直接放行
if (! config('app.demo_mode', 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);
// (后续:GET 放行 → 反射 #[DemoAllow] 放行 → 否则 403)
// 注解读取通过反射完成,反射异常时静默处理,视为未标注;
// 未放行的写操作统一返回 403,响应结构与 Result::fail() 一致。
}
}
```
@@ -141,7 +186,7 @@ class DemoMiddleware
### DemoAllow 注解
```php
// app/attribute/DemoAllow.php
// app/Attributes/DemoAllow.php
#[Attribute(Attribute::TARGET_METHOD)] // 仅支持方法级,不支持类级
class DemoAllow
@@ -150,63 +195,66 @@ class DemoAllow
}
```
### 注解读取(AttributeService)
### 注解读取(中间件内反射)
```php
// app/service/AttributeService.php
// DemoMiddleware 通过 ReflectionMethod 读取方法上的 #[DemoAllow]
$controllerClass = $route->getControllerClass();
$method = $route->getActionMethod();
// 通过反射判断方法是否标注了 #[DemoAllow]
public static function hasDemoAllow(string $controllerClass, string $method): bool
{
$attributes = self::getMethodAttributes($controllerClass, $method);
return $attributes['demoAllow'] !== null;
$reflection = new \ReflectionMethod($controllerClass, $method);
$attrs = $reflection->getAttributes(DemoAllow::class);
if (! empty($attrs)) {
return $next($request); // 白名单放行
}
```
:::details 注解解析流程
1. 以 `类名@方法名` 为键查缓存,命中直接返回
2. 通过 `ReflectionMethod` 获取方法上的所有 PHP 8 Attribute
3. 遍历属性实例,匹配 `DemoAllow` 类型并存入结果
4. 写入缓存,同一请求内不重复反射
:::tip 反射性能
注解读取开销为微秒级(与 `Authenticate`、`LogOperation` 一致),不做缓存——避免开发阶段改注解后需等缓存过期。
:::
## 白名单示例
系统中已标注 `#[DemoAllow]` 的方法:
当前代码中唯一标注 `#[DemoAllow]` 的方法:
| 控制器 | 方法 | 说明 | 为什么放行 |
|--------|------|------|-----------|
| `DictController` | `refreshCache` | 刷新字典缓存 | 仅清除缓存,不修改业务数据 |
| `GeneratorController` | `preview` | 代码生成预览 | 仅预览不写盘,展示生成器能力 |
| 控制器 | 方法 | 路由 | 说明 | 放行理由 |
|--------|------|------|------|---------|
| `GeneratorController` | `preview` | `GET generator/preview` | 代码生成预览 | 仅预览不写盘,不修改业务数据 |
:::tip GET 路由天然放行
`dict/refreshCache` 等维护接口均为 GET 方法,本就不受拦截、无需注解。`#[DemoAllow]` 的价值在于**防御性兜底**——即使后续将路由方法改为 POST,演示模式下仍可正常访问。
:::
### 使用示例
```php
use app\attribute\DemoAllow;
use app\attribute\Permission;
use app\attribute\Log;
use App\Attributes\DemoAllow;
use App\Attributes\OperationLog;
use App\Attributes\Permission;
class DictController extends BaseController
class GeneratorController extends Controller
{
/**
* 刷新字典缓存
* 预览生成代码
*
* 演示环境下也可执行:清除缓存不影响业务数据,
* 且是展示字典功能的必要操作。
* 标注 #[DemoAllow],演示环境下允许访问。
* 根据请求参数预览待生成的代码内容,不写入文件。
*/
#[Log('字典管理-刷新缓存', Log::TYPE_CLEAR)]
#[Permission('sys:dict:update', '修改字典')]
#[OperationLog('代码生成-预览代码', OperationLog::TYPE_QUERY)]
#[Permission('sys:generator:generate', '代码生成')]
#[DemoAllow] // ← 演示模式白名单
public function refreshCache(): Json
public function preview(Request $request): JsonResponse
{
DictService::clearAllCache();
return $this->success(null, '缓存刷新成功');
$result = $this->logic->preview($request->all());
return $this->success($result);
}
}
```
:::warning 注意事项
- `#[DemoAllow]` 仅支持方法级标注,不支持类级(与 `#[Permission]` 不同)
- `#[DemoAllow]` 仅支持方法级标注,不支持类级(与 `#[Permission]` 相同为方法级)
- 仅对"安全的写操作"使用,如刷新缓存、预览、导出等
- 不要对增删改数据的接口使用,否则失去演示模式的保护意义
:::
@@ -221,7 +269,7 @@ axios.interceptors.response.use(response => {
const { code, msg } = response.data;
// 演示模式拦截
if (code === 403 && msg === '演示环境,禁止操作') {
if (code === 403 && msg === '演示模式下禁止操作') {
ElMessage.warning('当前为演示环境,不允许执行此操作');
return Promise.reject(response.data);
}
@@ -245,31 +293,39 @@ axios.interceptors.response.use(response => {
</template>
<script setup>
import { useAppStore } from '@/store/modules/projectSetting';
const isDemoMode = computed(() => import.meta.env.VITE_DEMO_MODE === 'true');
</script>
```
## 响应示例
当演示模式拦截写操作时,返回:
当演示模式拦截写操作时,返回(HTTP 状态码 403):
```json
{
"code": 403,
"msg": "演示环境,禁止操作",
"ok": false,
"msg": "演示模式下禁止操作",
"data": null
}
```
HTTP 状态码为 `403`。
## 常见问题
### Q: 演示模式下登录还能用吗?
能。登录接口 `/api/login` 使用 POST 方法,但它位于**公开接口组**(不经过 DemoMiddleware),因此不受演示模式影响。
能。登录接口 `/api/login` 位于**公开接口组**(中间件组外),不经过 `demo` 中间件,因此不受演示模式影响。
### Q: 设置 DEMO_MODE=true 后仍能正常增删改?
演示模式生效需同时满足**配置接入**与**中间件挂载**两个条件,逐项排查(详见[启用与验证](#启用与验证)):
```bash
php artisan tinker --execute="dump(config('app.demo_mode'))" # 期望 true
php artisan route:list --path=user/add --json # middleware 应含 demo
```
若配置键缺失或中间件未挂载,改 `.env` 不会有任何效果。另外注意:执行过 `config:cache` 时,改完 `.env` 需重新缓存。
### Q: 如何让某个 POST 接口在演示模式下也能用?
@@ -277,7 +333,7 @@ HTTP 状态码为 `403`。
```php
#[DemoAllow]
public function mySafeAction(): Json
public function mySafeAction(): JsonResponse
{
// 演示模式下也可执行
}
@@ -286,18 +342,22 @@ public function mySafeAction(): Json
### Q: 演示模式和权限控制会冲突吗?
不会。两者是独立机制,按顺序执行:
1. **AuthMiddleware** 先校验权限(无权限返回 403 "无访问权限")
2. **DemoMiddleware** 再判断演示模式(拦截写操作返回 403 "演示环境,禁止操作")
1. **Authenticate** 先校验权限(无权限返回 403 "无访问权限:{权限码}")
2. **DemoMiddleware** 再判断演示模式(拦截写操作返回 403 "演示模式下禁止操作")
两者的 403 含义不同,前端可通过 `msg` 字段区分。
### Q: 临时关闭演示模式需要重启服务吗?
取决于 `.env` 的缓存配置:
- **文件缓存**:修改 `.env` 后需清缓存或重启(`php think clear`)
- **Redis 缓存**:同上
- **开发环境**(`APP_DEBUG=true`):每次请求重新读取 `.env`,无需重启
修改 `.env` 后需清除配置缓存并重启:
```bash
php artisan config:clear
# 生产环境如执行过 config:cache,改完需重新缓存
php artisan config:cache
```
### Q: `#[DemoAllow]` 支持类级标注吗?
不支持。`DemoAllow` 注解的 `Attribute` 目标仅为 `TARGET_METHOD`(方法级)。如果需要整个控制器放行,需在每个方法上单独标注。
不支持。`DemoAllow` 注解的 `Attribute` 目标仅为 `TARGET_METHOD`(方法级)。如果需要整个控制器放行,需在每个方法上单独标注。
+79 -101
View File
@@ -9,24 +9,25 @@
数据字典由两层组成:
```text
字典类型(think_dict) 字典项(think_dict_item)
字典类型(laravel_dict) 字典项(laravel_dict_item)
├── gender(性别) ├── 1 → 男
│ └── 2 → 女
├── user_status(用户状态) ├── 0 → 禁用
│ └── 1 → 启用
├── article_status(文章状态) ├── 0 → 草稿
│ └── 1 → 已发布
│ ├── 2 → 女
│ └── 3 → 保密
├── user_status(用户状态) ├── 1 → 正常
│ └── 2 → 禁用
├── example_status(案例状态) ├── 1 → 在用
│ └── 2 → 停用
└── ...
```
| 层级 | 表 | 说明 |
|------|-----|------|
| 字典类型 | `think_dict` | 字典分类(如"性别""用户状态") |
| 字典项 | `think_dict_item` | 字典的具体选项(如"1-男""2-女") |
| 字典类型 | `laravel_dict` | 字典分类(如"性别""用户状态") |
| 字典项 | `laravel_dict_item` | 字典的具体选项(如"1-男""2-女") |
### 表结构
**think_dict(字典主表):**
**laravel_dict(字典主表):**
| 字段 | 类型 | 说明 | 示例 |
|------|------|------|------|
@@ -40,7 +41,7 @@
| `update_user` | VARCHAR(50) | 更新人 | |
| `update_time` | DATETIME | 更新时间 | |
**think_dict_item(字典项表):**
**laravel_dict_item(字典项表):**
| 字段 | 类型 | 说明 | 示例 |
|------|------|------|------|
@@ -50,23 +51,19 @@
| `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 | 更新时间 | |
| `is_delete` + 四个审计字段 | | 公共字段 | |
## 系统内置字典
| 字典编码 | 字典名称 | 字典项 |
|----------|---------|--------|
| `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=仅本人 |
| `gender` | 性别 | 1=男, 2=女, 3=保密 |
| `user_status` | 用户状态 | 1=正常, 2=禁用 |
| `tenant_status` | 租户状态 | 1=启用, 0=禁用 |
| `example_type` | 案例类型 | 1=类型1, 2=类型2, 3=类型3, 4=类型4 |
| `example_status` | 案例状态 | 1=在用, 2=停用 |
(以数据库初始化脚本中的实际字典项为准,可在「数据管理 → 字典管理」中查看。)
## 实操:新增一个字典类型
@@ -84,7 +81,7 @@
| 字段 | 值 |
|------|-----|
| 字典名称 | 案例类型 |
| 字典编码 | example_type |
| 字典编码 | example_level |
| 状态 | 启用 |
3. 保存
@@ -101,24 +98,26 @@
| 2 | 进阶 | 2 |
| 3 | 高级 | 3 |
2. 保存
2. 保存(保存即触发 `DictItemObserver`,相关缓存自动失效)
### 第 3 步:在 Logic 中关联字典
### 第 3 步:在 Service 中关联字典
在模块的 Logic 中配置 `serializeMaps`:
在模块的 Service 中配置 `serializeMaps`(需 `use HasSerializeMaps`):
```php
class ExampleLogic extends BaseLogic
class ExampleService extends BaseService
{
use HasCrudOperations, HasQueryScopes, HasSerializeMaps, HasUniqueCheck;
// ... 其他配置 ...
/**
* 枚举显示名映射
* 字典翻译配置:[字段 => 字典编码]
*
* @var array
* @var array<string, string>
*/
protected array $serializeMaps = [
'type' => 'example_type',
'type' => 'example_level',
'status' => 'example_status',
];
}
@@ -126,20 +125,19 @@ class ExampleLogic extends BaseLogic
配置后,列表和详情接口会自动将 `type=1` 转换为 `typeText='基础'`。
### 第 4 步:在 Validate 中校验枚举值(可选)
### 第 4 步:在 FormRequest 中校验枚举值(可选)
```php
class ExampleValidate extends Validate
class ExampleRequest extends BaseRequest
{
protected $rule = [
'name' => 'require',
'type' => 'in:1,2,3', // 限制为合法枚举值
'status' => 'in:0,1',
];
protected $scene = [
'add' => ['name', 'type', 'status'],
'update' => ['name', 'type', 'status'],
];
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'type' => 'nullable|integer|in:1,2,3', // 限制为合法枚举值
'status' => 'nullable|integer|in:1,2',
];
}
}
```
@@ -148,44 +146,45 @@ class ExampleValidate extends Validate
### 方式 1:通过 serializeMaps 自动转换(推荐)
```php
// Logic 中配置后,查询数据自动补全
// Service 中配置后,查询数据自动补全
protected array $serializeMaps = [
'status' => 'example_status',
];
// 查询结果自动包含 statusText 字段
// { "id": 1, "name": "测试", "status": 1, "statusText": "启用" }
// { "id": 1, "name": "测试", "status": 1, "statusText": "在用" }
```
### 方式 2:手动调用 DictService
### 方式 2:手动调用 DictCacheService
```php
use app\service\DictService;
use App\Services\DictCacheService;
// 根据值获取名称
$text = DictService::getText('example_status', 1); // '启用'
$text = DictCacheService::getText('example_status', 1); // '在用'
// 根据名称反向获取值(导入时用)
$value = DictService::getValue('example_status', '启用'); // '1'
$value = DictCacheService::getValue('example_status', '在用'); // '1'
// 获取下拉框选项
$options = DictService::getOptions('example_status');
// [['label'=>'启用','value'=>'1'], ['label'=>'禁用','value'=>'0']]
$options = DictCacheService::getOptions('example_status');
// [['label'=>'在用','value'=>'1'], ['label'=>'停用','value'=>'2']]
// 获取完整字典项(含 id、sort、note)
$items = DictService::getFullItems('example_status');
// 批量获取多个字典映射(列表翻译一次取词)
$maps = DictCacheService::getBatchTextMaps(['example_type', 'example_status']);
```
### 方式 3:在导入时反向映射
```php
// ExampleLogic::import()
// ExampleService::import()(来自项目实际代码)
foreach ($data as &$row) {
// Excel 中"基础" → 数据库中 1
if (!empty($row['type']) && !is_numeric($row['type'])) {
$row['type'] = DictService::getValue('example_type', $row['type']);
// Excel 中"在用" → 数据库中 1
if (! empty($row['status']) && ! is_numeric($row['status'])) {
$row['status'] = DictCacheService::getValue('example_status', $row['status']);
}
}
unset($row);
```
## 在前端使用
@@ -193,8 +192,13 @@ foreach ($data as &$row) {
### 获取字典项
```typescript
// ui/src/api/common/index.ts
export function getDictItemList(code: string) {
// ui/src/api/common/index.ts(实际代码)
/**
* 根据字典编码查询字典项数据
* @param code 字典编码
* @returns 返回结果
*/
export function getDictItemByCode(code) {
return http.request({
url: '/dict/item/getDictItemList/' + code,
method: 'GET',
@@ -218,12 +222,12 @@ export function getDictItemList(code: string) {
<script setup>
import { ref, onMounted } from 'vue';
import { getDictItemList } from '@/api/common/index';
import { getDictItemByCode } from '@/api/common/index';
const statusOptions = ref([]);
onMounted(async () => {
statusOptions.value = await getDictItemList('example_status');
statusOptions.value = await getDictItemByCode('example_status');
});
</script>
```
@@ -233,41 +237,11 @@ onMounted(async () => {
```typescript
// 同时获取多个字典选项
const [genderOptions, statusOptions] = await Promise.all([
getDictItemList('gender'),
getDictItemList('user_status'),
getDictItemByCode('gender'),
getDictItemByCode('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
@@ -280,23 +254,27 @@ const { options: statusOptions } = useDict('user_status');
## 缓存管理
DictService 采用两级缓存(请求级 + 持久缓存,1小时过期)。修改字典数据后需刷新缓存:
DictCacheService 采用两级缓存(请求级 + 持久缓存)。修改字典数据后的缓存失效:
```bash
# 调用刷新接口
# 手动刷新接口(认证组内)
GET /api/dict/refreshCache
# 命令行兜底
php artisan dict:clear-cache
```
### 缓存失效时机
| 时机 | 操作 | 说明 |
|------|------|------|
| 字典项新增/修改/删除 | 自动清除该字典缓存 | DictLogic 中调用 `DictService::clearCache($code)` |
| 手动刷新 | `GET /dict/refreshCache` | 清除所有字典缓存 |
| 缓存过期 | 自动失效 | 1 小时 TTL |
| 字典项新增/修改/删除 | **自动清除** | `DictItemObserver` 监听模型事件 |
| 手动刷新 | `GET /api/dict/refreshCache` | 清除所有字典缓存(兜底) |
| 命令行清理 | `php artisan dict:clear-cache` | 直接改库等绕过模型事件时使用 |
| 缓存过期 | 自动失效 | TTL 见 `DictCacheService::CACHE_TTL` |
:::warning 字典数据变更后必须刷新缓存
修改字典项后,如果不刷新缓存,前端显示的可能仍是旧数据。建议在字典管理的增删改接口中自动调用 `clearCache()`。
:::tip
正常走后台接口修改字典会自动失效缓存;只有**绕过模型直接操作数据库**(如手工 SQL 改字典项)才需要手动刷新。
:::
## 字典 vs 硬编码
@@ -308,7 +286,7 @@ GET /api/dict/refreshCache
**何时用字典:** 值可能变化(状态、类型、分类)、需要前端下拉框、需要多处复用
**何时硬编码:** 值几乎不变(性别只有男女)、单次使用、性能极致敏感
**何时硬编码:** 值几乎不变、单次使用、性能极致敏感
## 命名规范
@@ -329,5 +307,5 @@ GET /api/dict/refreshCache
| 标签用中文 | 字典标签使用中文(`启用`、`禁用`),便于前端直接显示 |
| 排序有序 | 通过 `sort` 字段控制下拉框选项顺序 |
| 备注说明 | 在 `note` 字段记录特殊值的含义,便于维护 |
| Validate 校验 | 在 Validate 中用 `in:0,1` 限制合法值 |
| 导入导出 | 导入时用 `getValue` 反向映射,导出时用 `getText` 正向翻译 |
| FormRequest 校验 | 在 rules 中用 `in:1,2` 限制合法值 |
| 导入导出 | 导入时用 `getValue` 反向映射,导出时用 `getText`/`serializeMaps` 正向翻译 |
+93 -94
View File
@@ -1,7 +1,7 @@
# 5.10 字典模块
:::tip 概述
DictService 提供字典项的读取、正反向转换与缓存管理,是字典体系的核心服务。采用两级缓存(请求级 + 持久缓存),同一请求内不重复查库,跨请求复用持久缓存。
字典体系由三个服务组成:`DictService`(字典 CRUD,`DictController` 注入)、`DictItemService`(字典项 CRUD)、`DictCacheService`(字典项读取、正反向转换与缓存管理,本页主体)。读取侧采用两级缓存(请求级 + 持久缓存),同一请求内不重复查库,跨请求复用持久缓存;字典项变更时 `DictItemObserver` 自动失效对应缓存。
:::
## 字典体系架构
@@ -11,7 +11,7 @@ DictService 提供字典项的读取、正反向转换与缓存管理,是字
│ 字典体系 │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ think_dict │ │ think_dict_item │ │
│ │ laravel_dict │ │ laravel_dict_item│ │
│ │ 字典主表 │ │ 字典项表 │ │
│ │ │ │ │ │
│ │ id │◄───│ dict_id │ │
@@ -22,24 +22,28 @@ DictService 提供字典项的读取、正反向转换与缓存管理,是字
│ └──────────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ DictService │ │ Logic 层 │ │
│ │ 字典服务 │ │ serializeMaps │ │
│ │ │ │ 自动翻译 │ │
│ │ DictCacheService │ │ Service 层 │ │
│ │ 字典服务 │ │ serializeMaps │ │
│ │ │ │ 自动翻译 │ │
│ │ getText() │ │ │ │
│ │ getValue() │◄───│ 'status' => │ │
│ │ getOptions() │ │ 'user_status' │ │
│ │ clearCache() │ │ │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ DictItemObserver(字典项变更 → 自动清除所属字典缓存) │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
## 缓存架构
```text
请求级缓存(static::$localCache) ← 同一请求内不重复查库
请求级缓存(静态变量) ← 同一请求内不重复查库
│ 未命中
▼
持久缓存(cache() 门面,1小时) ← 跨请求复用(file/redis)
持久缓存(cache() 门面,1小时) ← 跨请求复用(Redis)
│ 未命中
▼
数据库查询(dict → dict_item) ← 查询后写入两级缓存
@@ -50,9 +54,7 @@ DictService 提供字典项的读取、正反向转换与缓存管理,是字
| 缓存类型 | 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}, ...]` |
| 持久缓存 | `dict_gender`(前缀 `CACHE_PREFIX='dict_'`) | `['1'=>'男', '2'=>'女']` |
### 缓存生命周期
@@ -60,8 +62,8 @@ DictService 提供字典项的读取、正反向转换与缓存管理,是字
1. 首次查询 → 数据库 → 写入持久缓存 + 请求级缓存
2. 同一请求再次命中 → 直接返回请求级缓存(最快)
3. 不同请求命中 → 读取持久缓存(不查库)
4. 缓存过期(1小时)→ 重新查库 → 更新两级缓存
5. 字典数据变更 → 调用 clearCache() 清除 → 下次查询重新加载
4. 缓存过期(TTL 见 DictCacheService::CACHE_TTL)→ 重新查库 → 更新两级缓存
5. 字典项变更 → DictItemObserver 自动调用 clearCache() → 下次查询重新加载
```
## 核心方法
@@ -69,10 +71,10 @@ DictService 提供字典项的读取、正反向转换与缓存管理,是字
### getText — 根据值获取名称(最常用)
```php
DictService::getText('gender', 1); // '男'
DictService::getText('user_status', 1); // '启用'
DictService::getText('gender', ''); // ''(空值返回空字符串)
DictService::getText('gender', null); // ''(空值返回空字符串)
DictCacheService::getText('gender', 1); // '男'
DictCacheService::getText('user_status', 1); // '正常'
DictCacheService::getText('gender', ''); // ''(空值返回空字符串)
DictCacheService::getText('gender', null); // ''(空值返回空字符串)
```
**用途:** 列表展示时将数据库值转换为可读名称。
@@ -80,17 +82,17 @@ DictService::getText('gender', null); // ''(空值返回空字符串)
### getValue — 根据名称反向获取值
```php
DictService::getValue('gender', '男'); // '1'
DictService::getValue('user_status', '启用'); // '1'
DictService::getValue('gender', ''); // ''(空值返回空字符串)
DictCacheService::getValue('gender', '男'); // '1'
DictCacheService::getValue('user_status', '正常'); // '1'
DictCacheService::getValue('gender', ''); // ''(空值返回空字符串)
```
**用途:** Excel 导入时将中文文字转换为数据库存储的数值。
**用途:** Excel 导入时将中文文字转换为数据库存储的数值(`ExampleService::import()` 即用此方法反查状态文本)。
### getOptions — 获取下拉框选项
```php
DictService::getOptions('gender');
DictCacheService::getOptions('gender');
// [
// ['label' => '男', 'value' => '1'],
// ['label' => '女', 'value' => '2'],
@@ -99,51 +101,58 @@ DictService::getOptions('gender');
**用途:** 前端下拉框、单选框、复选框组件的数据源。
### getFullItems — 获取完整字典项
### getItems — 获取值→名称映射
```php
DictService::getFullItems('gender');
DictCacheService::getItems('gender');
// ['1' => '男', '2' => '女']
```
### getBatchTextMaps — 批量获取多个字典的映射
```php
DictCacheService::getBatchTextMaps(['gender', 'user_status']);
// [
// ['id' => 1, 'name' => '男', 'value' => '1', 'sort' => 1, 'note' => ''],
// ['id' => 2, 'name' => '女', 'value' => '2', 'sort' => 2, 'note' => ''],
// 'gender' => ['1' => '男', '2' => '女'],
// 'user_status' => ['1' => '正常', '2' => '禁用'],
// ]
```
**用途:** 需要 id、排序、备注等完整信息的场景。
**用途:** 列表翻译时一次取多个字典,避免逐个查询。
### clearCache — 清除缓存
### clearCache / clearAllCache — 缓存管理
```php
DictService::clearCache('gender'); // 清除指定字典的缓存
DictService::clearCache(); // 仅清空请求级缓存
DictService::clearAllCache(); // 清除所有字典持久缓存
DictCacheService::clearCache('gender'); // 清除指定字典的缓存
DictCacheService::clearCache(); // 仅清空请求级缓存
DictCacheService::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` |
| `getText($dictCode, $value)` | 值 → 名称 | `string` |
| `getValue($dictCode, $text)` | 名称 → 值 | `string` |
| `getItems($dictCode)` | 获取值→名称映射 | `array` `['1'=>'男', '2'=>'女']` |
| `getOptions($dictCode)` | 获取下拉框选项 | `array` `[['label'=>'男','value'=>'1'], ...]` |
| `getBatchTextMaps($codes)` | 批量获取多个字典映射 | `array` `[code => map, ...]` |
| `clearCache($dictCode?)` | 清除指定/请求级缓存 | `void` |
| `clearAllCache()` | 清除所有字典缓存 | `void` |
## 在 Logic 中使用
## 在 Service 中使用
### 自动翻译(serializeMaps)
配置 `serializeMaps` 后,查询数据时自动补全 `{字段名}Text` 字段,无需手动调用 DictService:
配置 `serializeMaps` 后(需 `use HasSerializeMaps`),查询数据时自动补全 `{字段名}Text` 字段,无需手动调用 DictCacheService:
```php
class UserLogic extends BaseTenantLogic
class UserService extends BaseTenantService
{
/**
* 枚举显示名映射
* 字典翻译配置:[字段 => 字典编码]
*
* @var array
* @var array<string, string>
*/
protected array $serializeMaps = [
'gender' => 'gender',
@@ -157,38 +166,29 @@ class UserLogic extends BaseTenantLogic
```text
数据库:{ gender: 1, status: 1 }
│
▼ processSerializeMaps()
│ DictService::getText('gender', 1) → '男'
│ DictService::getText('user_status', 1) → '启用'
▼ processSerializeMapsList()
│ DictCacheService 批量取词(getBatchTextMaps,同一请求只查一次)
│ getText('gender', 1) → '男'
│ getText('user_status', 1) → '正常'
▼
前端接收:{ gender: 1, genderText: '男', status: 1, statusText: '启用' }
前端接收:{ gender: 1, genderText: '男', status: 1, statusText: '正常' }
```
### 手动调用
```php
// 在 Logic 或 Service 中手动调用
$genderText = DictService::getText('gender', $user['gender']);
$options = DictService::getOptions('gender');
// 在 Service 中手动调用
$genderText = DictCacheService::getText('gender', $user['gender']);
$options = DictCacheService::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);
}
```bash
# routes/api.php(认证组内)
GET /api/dict/item/getDictItemList/{code}
```
### 刷新字典缓存
@@ -197,11 +197,11 @@ public function getOptions(string $code): Json
/**
* 刷新字典缓存
*
* GET /dict/refreshCache
* GET /api/dict/refreshCache
*/
public function refreshCache(): Json
public function refreshCache(): JsonResponse
{
DictService::clearAllCache();
DictCacheService::clearAllCache();
return $this->success(null, '缓存刷新成功');
}
```
@@ -211,26 +211,22 @@ public function refreshCache(): Json
### 导入时:文字转值
```php
// UserLogic::import()
// ExampleService::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']);
// Excel 中"在用" → 数据库中 1
if (! empty($row['status']) && ! is_numeric($row['status'])) {
$row['status'] = DictCacheService::getValue('example_status', $row['status']);
}
}
unset($row);
```
### 导出时:值转文字
```php
// 导出表头使用 Text 后缀字段
// 导出表头使用 Text 后缀字段(由 serializeMaps 自动补全)
$headers = [
'name' => '姓名',
'genderText' => '性别', // 自动翻译后的字段
'name' => '名称',
'statusText' => '状态', // 自动翻译后的字段
];
```
@@ -241,14 +237,14 @@ $headers = [
```typescript
// API
export function getDictOptions(code: string) {
return http.request({ url: `/dict/getOptions/${code}`, method: 'GET' });
export function getDictItemByCode(code: string) {
return http.request({ url: `/dict/item/getDictItemList/${code}`, method: 'GET' });
}
// 使用
const genderOptions = ref([]);
onMounted(async () => {
genderOptions.value = await getDictOptions('gender');
genderOptions.value = await getDictItemByCode('gender');
});
```
@@ -260,7 +256,7 @@ onMounted(async () => {
<el-option
v-for="item in genderOptions"
:key="item.value"
:label="item.label"
:label="item.name"
:value="item.value"
/>
</el-select>
@@ -269,7 +265,7 @@ onMounted(async () => {
## 数据库表结构
### think_dict(字典主表)
### laravel_dict(字典主表)
| 字段 | 类型 | 说明 | 示例 |
|------|------|------|------|
@@ -279,13 +275,13 @@ onMounted(async () => {
| `remark` | VARCHAR(500) | 备注 | 性别字典 |
| `...` | | 公共字段 | |
### think_dict_item(字典项表)
### laravel_dict_item(字典项表)
| 字段 | 类型 | 说明 | 示例 |
|------|------|------|------|
| `id` | INT | 主键 | 1 |
| `dict_id` | INT | 字典 ID(外键) | 1 |
| `name` | VARCHAR(50) | 项名称 | 男 |
| `name` | VARCHAR(50) | 项名称(显示标签) | 男 |
| `value` | VARCHAR(50) | 项值 | 1 |
| `sort` | INT | 排序 | 1 |
| `note` | VARCHAR(200) | 备注 | |
@@ -295,12 +291,13 @@ onMounted(async () => {
| 字典编码 | 名称 | 项值 | 用途 |
|----------|------|------|------|
| `gender` | 性别 | 0=女, 1=男 | 用户性别 |
| `user_status` | 用户状态 | 0=禁用, 1=启用 | 用户账号状态 |
| `article_status` | 文章状态 | 0=草稿, 1=已发布 | 文章状态 |
| `tenant_status` | 租户状态 | 0=禁用, 1=启用 | 租户状态 |
| `example_type` | 案例类型 | 0=类型一, 1=类型二 | 案例分类 |
| `example_status` | 案例状态 | 0=禁用, 1=启用 | 案例状态 |
| `gender` | 性别 | 1=男, 2=女, 3=保密 | 用户性别 |
| `user_status` | 用户状态 | 1=正常, 2=禁用 | 用户账号状态 |
| `tenant_status` | 租户状态 | 1=启用, 0=禁用 | 租户状态 |
| `example_type` | 案例类型 | 1=类型1, 2=类型2, 3=类型3, 4=类型4 | 案例分类 |
| `example_status` | 案例状态 | 1=在用, 2=停用 | 案例状态 |
(项值以数据库初始化脚本中的实际字典项为准。)
## 缓存管理
@@ -308,12 +305,13 @@ onMounted(async () => {
| 时机 | 操作 | 说明 |
|------|------|------|
| 字典项新增/修改/删除 | `DictService::clearCache($code)` | 清除该字典的缓存 |
| 手动刷新 | `GET /dict/refreshCache` | 清除所有字典缓存 |
| 缓存过期 | 自动失效 | 1 小时 TTL |
| 字典项新增/修改/删除 | **`DictItemObserver` 自动清除** | 模型事件触发,无需手动 |
| 手动刷新 | `GET /api/dict/refreshCache` | 清除所有字典缓存 |
| 命令行清理 | `php artisan dict:clear-cache` | 运维兜底 |
| 缓存过期 | 自动失效 | TTL 见 `CACHE_TTL` |
:::warning 字典数据变更后必须刷新缓存
修改字典项后,如果不刷新缓存,前端显示的可能仍是旧数据。建议在字典管理的增删改接口中自动调用 `clearCache()`。
:::tip 缓存一致性已自动保障
字典项(`DictItem`)保存/删除时 `DictItemObserver` 自动调用 `clearCache($code)`——业务代码无需手动刷新;上面的手动接口与命令仅作兜底(如直接改库绕过模型事件)。
:::
## 安全特性
@@ -324,3 +322,4 @@ onMounted(async () => {
| 空值安全 | `getText`/`getValue` 对 null/空字符串返回空字符串 |
| 类型安全 | 值统一转为字符串比较,避免类型松散比较问题 |
| 缓存隔离 | 每个字典编码独立缓存,互不影响 |
| 自动失效 | Observer 保证数据变更后缓存不脏 |
+190 -46
View File
@@ -1,77 +1,221 @@
# 5.16 异常与响应处理
# 5.16 统一响应与异常抛出
:::tip 概述
ExceptionHandle 将所有异常统一渲染为 JSON 响应,保证前端始终拿到结构一致的错误信息。
本页从**后端开发视角**讲解两件事:接口如何返回统一响应(`Result` 类 + Controller 快捷方法),业务异常如何抛出并被 `Handler` 统一渲染为 JSON。前端报文格式见 [8.1 统一响应格式](/zh/api-spec/response.html),规范约束见 [12.1 API 响应规范](/zh/standards/api-response.html)。
:::
## 响应结构
所有接口(含成功与失败)返回统一 JSON 结构,HTTP 状态码默认 200,前端以 body 中的 `code` 判断结果:
```json
// 成功
{"code": 0, "ok": true, "msg": "操作成功", "data": {...}}
// 失败
{"code": 1, "ok": false, "msg": "操作失败", "data": null}
// 分页
{"code": 0, "ok": true, "msg": "操作成功",
"data": {"records": [...], "total": 100, "current": 1, "size": 20, "pages": 5}}
```
## 异常处理优先级
## 统一响应类(Result)
`App\Support\Result` 是所有响应的构造入口——Controller 快捷方法与全局异常处理器都委托给它,保证全站结构一致。
### 业务码常量
| 常量 | 值 | 含义 | HTTP 状态码 |
|------|----|------|-------------|
| `Result::SUCCESS` | 0 | 成功 | 200 |
| `Result::FAIL` | 1 | 一般失败(业务逻辑失败) | 200 |
| `Result::UNAUTHORIZED` | 401 | 未授权(Token 缺失/无效/过期) | 200 |
| `Result::FORBIDDEN` | 403 | 禁止访问(权限不足 / 租户异常 / 演示环境) | **403** |
| `Result::NOT_FOUND` | 404 | 数据不存在 / 路由不存在 | 200 |
| `Result::VALID_ERROR` | 422 | 参数验证错误 | 200 |
:::tip HTTP 状态码约定
除 `forbidden()` 同时设置 HTTP 403(便于网关按 HTTP 状态码处理)外,其余响应 HTTP 状态码恒为 200,结果一律看 body 的 `code`(`code=401` 时前端跳转登录)。
:::
### 方法速查
```php
// app/ExceptionHandle.php
Result::success($data, $msg); // 成功响应(data 自动驼峰转换)
Result::success($data, $msg, true); // 成功响应,$raw=true 跳过驼峰转换
Result::page($records, $total, $current, $size, $pages = null); // 分页响应
Result::fail($msg, $code, $data); // 失败响应($code 默认 1)
Result::unauthorized($msg); // 401 未授权(HTTP 200 + body code=401)
Result::forbidden($msg); // 403 禁止访问(HTTP 403 + body code=403)
Result::notFound($msg); // 404 数据不存在(body code=404)
Result::validateError($errors); // 422 验证错误(data=字段错误明细)
```
public function render($request, Throwable $e): Response
### Controller 快捷方法
控制器基类把常用方法转发到 `Result`,在控制器中直接用 `$this->` 调用:
```php
// app/Http/Controllers/Controller.php
$this->success($data, '操作成功'); // → Result::success()
$this->paginate($result); // → Result::page(),$result 来自 Service::pageList()
$this->fail('操作失败'); // → Result::fail(),$code 默认 1
$this->fail('数据不存在', 404); // → 自定义 code
// 通用 CRUD 快捷方法(内部已处理响应与异常,控制器一行调用)
$this->_page($service); // 分页 $this->_detail($service, $id); // 详情(空返回 404)
$this->_list($service); // 全量列表 $this->_add($service, $data); // 新增(返回 id)
$this->_treeList($service); // 树形列表 $this->_update($service, $id, $data);
$this->_delete($service, $id); // 删除 $this->_batchDelete($service, $ids);
```
## 抛出业务异常(BizException)
业务规则校验失败时,在 **Service 层**抛出 `App\Exceptions\BizException`,无须自己构造响应:
```php
use App\Exceptions\BizException;
// 典型场景:唯一性校验、状态依赖校验
if ($this->uniqueCheck('code', $data, $id)) {
throw new BizException('编码已存在');
}
if ($model->status !== 1) {
throw new BizException('该状态下不允许删除');
}
```
**工作机制:**
- `BizException` 继承 `RuntimeException`,属于"预期内"的业务异常,默认 `code=1`;
- 走 `_add() / _update() / _delete()` 等快捷方法时,被 Controller 捕获并转为 `$this->fail($e->getMessage(), $e->getCode())`;
- 没被捕获时由全局 `Handler` 兜底处理,仍返回 `code=1` 的失败响应——**任何路径下前端拿到的结构都一致**。
:::warning 使用边界
`BizException` 仅用于**业务规则失败**(消息直接展示给用户,不含敏感信息)。程序错误(空指针、SQL 异常等)应让其自然冒泡,由 `Handler` 统一转为 `code=500`,生产环境隐藏真实消息——不要把未知异常包装成业务异常掩盖问题。
:::
## 异常处理流程(Handler)
`app/Exceptions/Handler.php` 拦截 `api/*` 或期望 JSON 的请求,按异常类型分派:
```text
任意异常抛出
│
▼
Handler::render() ── 非 api/* 且非 JSON 请求 → 交回框架默认处理(如网页 404)
│
▼(api/* 请求)
renderJson() 按类型分派:
├─ BizException → Result::fail(消息, 异常 code ?: 1)
├─ ValidationException → Result::validateError(字段明细) code=422
├─ AuthenticationException → Result::unauthorized() code 401(HTTP 200)
├─ NotFoundHttpException → Result::notFound() code=404
└─ 其它异常 → Result::fail(消息, 500)
APP_DEBUG=true 时消息=异常原文,
生产环境消息固定为「操作失败」
```
对应代码:
```php
// app/Exceptions/Handler.php(节选)
protected function renderJson($request, Throwable $e): JsonResponse
{
// 1. 参数验证失败
if ($e instanceof ValidateException) {
return json(['code' => 1, 'ok' => false, 'msg' => '参数验证失败', 'data' => $e->getError()]);
if ($e instanceof BizException) {
return Result::fail($e->getMessage(), $e->getCode() ?: 1);
}
if ($e instanceof ValidationException) {
return Result::validateError($e->errors());
}
if ($e instanceof AuthenticationException) {
return Result::unauthorized();
}
if ($e instanceof NotFoundHttpException) {
return Result::notFound();
}
// 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]);
// 其他异常:生产环境隐藏细节,调试模式返回真实消息
$msg = config('app.debug') ? $e->getMessage() : '操作失败';
return Result::fail($msg ?: '操作失败', 500);
}
```
:::danger 生产环境安全
`SHOW_ERROR_MSG` 必须设为 `false`,否则未知异常的详细信息(可能包含 SQL 语句、文件路径等敏感信息)会暴露给前端。
`APP_DEBUG` 必须为 `false`,否则未知异常的真实消息(可能包含 SQL 语句、文件路径等敏感信息)会通过 `msg` 返回给前端。详见 [11.8 安全加固清单](/zh/deploy/security.html)。
:::
## 统一响应类(Result)
## 参数验证的两条路径
```php
// extend/response/Result.php
参数验证失败有两条路径,**code 不同**,联调时注意区分:
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 验证错误
| 路径 | 触发方式 | 响应 |
|------|----------|------|
| FormRequest(控制器入参) | 路由类型注入 `XxxRequest`,`rules()` 不通过 | `code=1`,`msg=参数验证失败`,`data=字段明细`(`BaseRequest::failedValidation()` 统一处理,与 `fail()` 默认 code 一致) |
| ValidationException(代码内手动校验) | `Validator::validate()` / `$request->validate()` 抛出 | `code=422`(`Handler` 分派到 `Result::validateError()`) |
FormRequest 失败响应示例:
```json
{"code": 1, "ok": false, "msg": "参数验证失败",
"data": {"username": ["用户名不能为空"], "password": ["密码不能为空"]}}
```
## 状态码说明
## 完整示例
| code | 含义 | 使用场景 |
|------|------|----------|
| 0 | 成功 | 所有成功操作 |
| 1 | 一般失败 | 业务逻辑失败 |
| 401 | 未授权 | Token 缺失/无效/过期 |
| 403 | 禁止访问 | 权限不足 / 租户异常 / 演示环境 |
| 404 | 数据不存在 | 查询单条记录为空 |
| 422 | 参数验证失败 | 表单验证不通过 |
```php
// Controller:参数由 FormRequest 验证,响应交给快捷方法
public function add(ExampleAddRequest $request)
{
return $this->_add($this->service, $request->validated());
}
// Service:业务规则失败直接抛异常
public function add(array $data): int
{
if ($this->uniqueCheck('code', $data)) {
throw new BizException('编码已存在');
}
return $this->doAdd($data); // DB::transaction 由基类钩子处理
}
// 手动构造响应(非 CRUD 场景)
public function custom()
{
$data = $this->service->stats();
if (! $data) {
return $this->fail('暂无统计数据', 404);
}
return $this->success($data);
}
```
## 驼峰转换
`Result` 构造响应时自动把 `snake_case` 键转为 `camelCase`(由 `config/api.php` 的 `API_CAMEL_SNAKE_CONVERT` 控制,请求方向反向转换):
- 响应:`create_time` → `createTime`
- 需要原样返回时:`Result::success($data, $msg, true)`(`$raw=true`)
## 常见问题
| 问题 | 说明 |
|------|------|
| 为什么 HTTP 200 却是失败? | 约定 body `code` 判断结果,HTTP 状态码仅 403 例外(便于网关处理);`code=401` 由前端跳转登录 |
| 前端拿到 `code=500` 且 `msg=操作失败` | 未知异常 + 生产模式;排查服务端日志 `storage/logs/` |
| 抛了 BizException 却返回 500? | 检查是否被外层 `try/catch (\Exception)` 吞掉后重新抛出,或消息中混入了敏感信息 |
| 验证失败有时 1 有时 422? | 见上文「参数验证的两条路径」,前端两种都要兼容(项目以 `code !== 0` 判失败) |
| 分页字段顺序为何固定? | `Result::page()` 固定 `records / total / current / size / pages` 五项,与 `Service::pageList()` 返回结构对应 |
## 相关页面
- [8.1 统一响应格式](/zh/api-spec/response.html) — 前端对接视角的报文格式
- [8.2 错误码对照表](/zh/api-spec/error-code.html) — 全部错误码速查
- [12.1 API 响应规范](/zh/standards/api-response.html) — 必须遵守的规范约束
- [3.6 控制器开发](/zh/guide/controller.html) — 快捷方法的完整用法
@@ -1,151 +0,0 @@
# 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已存在")
```
+158 -167
View File
@@ -1,7 +1,7 @@
# 5.1 用户认证(JWT)
:::tip 概述
JWT(JSON Web Token)是系统的核心认证机制。采用双令牌设计:access_token 用于接口认证(2小时),refresh_token 用于令牌刷新(7天)。无状态、可水平扩展,前后端分离场景下的标准方案。
JWT(JSON Web Token)是系统的核心认证机制。采用双令牌设计:access_token 用于接口认证(2 小时),refresh_token 用于令牌刷新(7 天)。refresh_token 存储于 Redis(有状态),支持**一次性刷新、主动吊销、踢下线、改密后全端失效**;access_token 无状态、可水平扩展。
:::
## 双令牌机制
@@ -10,13 +10,14 @@ JWT(JSON Web Token)是系统的核心认证机制。采用双令牌设计:
┌──────────┐ ┌──────────┐
│ 用户登录 │──── username ────►│ │
│ │──── password ────►│JwtService│
│ │──── code/key ────►│ .login()│
│ │──── key/code ────►│ .login()│
└──────────┘ └────┬─────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
access_token refresh_token user info
(2小时有效) (7天有效) (基础信息)
存 Redis(jti 一次性)
```
### 两种令牌的区别
@@ -24,20 +25,22 @@ JWT(JSON Web Token)是系统的核心认证机制。采用双令牌设计:
| 特性 | access_token | refresh_token |
|------|-------------|---------------|
| 用途 | 接口认证(每个 API 请求携带) | 刷新令牌(仅在 access_token 过期时使用) |
| 有效期 | 2 小时(7200 秒) | 7 天(604800 秒) |
| 有效期 | 2 小时(`JWT_ACCESS_TTL=7200`) | 7 天(`JWT_REFRESH_TTL=604800`) |
| payload.type | `access` | `refresh` |
| 传递方式 | `Authorization: Bearer <token>` | POST `/oauth2/token` 请求体 |
| 校验方 | AuthMiddleware | JwtService::refreshToken |
| 传递方式 | `Authorization: Bearer <token>` | POST `/api/refresh` 请求体 |
| 服务端状态 | 无状态(不落库) | **有状态**:`refresh:{uid}:{jti}` 存 Redis |
| 校验方 | `Authenticate` 中间件 | `JwtService::refreshToken()` |
### 令牌流转全生命周期
```text
1. 登录
POST /api/login {username, password, code, key}
POST /api/login {username, password, key, code}
│
▼
JwtService::login()
│ 签发 access_token + refresh_token
│ 同一 jti(UUID),refresh_token 存入 Redis(TTL 7 天)
▼
前端存储到 localStorage
@@ -46,81 +49,84 @@ JWT(JSON Web Token)是系统的核心认证机制。采用双令牌设计:
Authorization: Bearer <access_token>
│
▼
AuthMiddleware::handle()
Authenticate::handle()
│ 解析 → 校验签名 → 校验有效期 → 校验 type=access
│ 注入 $request->userInfo
│ 注入 attributes['userInfo']
▼
Controller → Logic → Model → 响应
Controller → Service → Model → 响应
3. Token 过期
前端响应拦截器检测 code=401
3. Token 过期(响应 code=401)
│
▼
POST /api/oauth2/token {grant_type: refresh_token, refresh_token: <refresh_token>}
POST /api/refresh {refresh_token: <refresh_token>}
│
▼
JwtService::refreshToken()
│ 校验 type=refresh → 校验用户状态 → 签发新令牌对
│ 校验 type=refresh → Lua 原子校验并删除 Redis 中旧 jti(一次性)
│ → 校验用户状态 → 签发新令牌对(新 jti)
▼
前端更新 localStorage 中的令牌
4. 登出
前端清除 localStorage 中的令牌
(JWT 无状态,服务端无需处理)
POST /api/logout(携带 access_token)
│
▼
JwtService::revokeToken()
│ 解析 uid + jti → 删除 Redis refresh:{uid}:{jti}
▼
refresh_token 即时失效(旧 access_token 到期后无法续签)
5. 踢下线 / 改密失效
JwtService::revokeAllTokens($userId)
│ SCAN 删除 refresh:{uid}:* 全部令牌
│ 删除 perms:{uid} 权限缓存
▼
该用户所有设备立即掉线
```
:::tip 为什么 refresh 要有状态?
纯无状态 JWT 无法在"过期前"作废。refresh_token 落 Redis 后,登出、改密、封号、踢下线都能**即时生效**;配合 Lua 脚本原子删除实现"一次性刷新"(旧 refresh_token 用过即废,防止重放)。
:::
## 配置项
| 配置键 | 环境变量 | 默认值 | 说明 |
| 配置键(`config/jwt.php`) | 环境变量 | 默认值 | 说明 |
|--------|---------|--------|------|
| `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 ` | 请求头前缀 |
| `jwt.secret` | `JWT_SECRET` | (必填) | JWT 签名密钥,**必须 ≥ 32 字符,否则拒绝启动** |
| `jwt.access_ttl` | `JWT_ACCESS_TTL` | `7200` | access_token 有效期(秒),2 小时 |
| `jwt.refresh_ttl` | `JWT_REFRESH_TTL` | `604800` | refresh_token 有效期(秒),7 天 |
| `jwt.perms_ttl` | `JWT_PERMS_TTL` | `7200` | 权限缓存 TTL(秒) |
| `jwt.issuer` | `JWT_ISSUER` | `rxthinkcmf` | 签发者标识(iss 字段) |
| `jwt.header_prefix` | — | `Bearer ` | 请求头前缀(含尾部空格) |
:::warning 密钥安全(fail-close)
`JWT_SECRET` 为空或不足 32 字符时,`AppServiceProvider` 启动校验直接抛异常,**应用拒绝启动**——弱密钥无法上线。生成方式:
:::warning 密钥安全
生产环境务必通过 `.env` 设置 `JWT.SECRET` 为强随机密钥:
```bash
openssl rand -base64 32
# 或
php -r "echo bin2hex(random_bytes(32));"
```
:::
## 核心类说明
### Jwt 工具类(extend/jwt/Jwt.php)
### JwtService 服务类(`app/Services/JwtService.php`)
对 `firebase/php-jwt` 的薄封装,只做令牌的编解码与提取,不涉及业务逻辑。
基于 `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 |
| `login($username, $password)` | 用户登录,签发令牌对 | `['access_token', 'refresh_token', 'token_type', 'expires_in', 'user']`;失败抛 `BizException` |
| `refreshToken($refreshToken)` | 用 refresh_token 换新令牌对(**旧 jti 一次性删除**) | `['access_token', 'refresh_token', 'token_type', 'expires_in']` |
| `parseToken($token)` | 解析 access_token(仅接受 `type=access`) | `object`(含 uid、username、jti、exp) |
| `checkToken($token)` | 检测令牌有效性(**不抛异常**) | `['valid' => true/false, ...]` |
| `revokeToken($token)` | 吊销单个 refresh_token(登出) | `bool` |
| `revokeAllTokens($userId)` | 吊销用户全部令牌 + 权限缓存(踢下线/改密) | `bool` |
| `createTokenPair($user)` | 生成 jti 并签发新令牌对(内部) | 令牌对数组 |
| `loadPermissions($userId)` | 加载用户权限并缓存 `perms:{uid}` | 权限数组 |
**异常处理:** `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` |
**降级策略**:Redis 不可用时不阻塞登录(access_token 照常签发),但刷新的 jti 校验、踢下线能力失效——设计上"可用性优先、安全能力降级"。
## Token Payload 结构
@@ -131,6 +137,7 @@ php -r "echo bin2hex(random_bytes(32));"
"uid": 1,
"username": "admin",
"type": "access",
"jti": "550e8400-e29b-41d4-a716-446655440000",
"iss": "rxthinkcmf",
"iat": 1727000000,
"exp": 1727007200
@@ -144,187 +151,171 @@ php -r "echo bin2hex(random_bytes(32));"
"uid": 1,
"username": "admin",
"type": "refresh",
"jti": "550e8400-e29b-41d4-a716-446655440000",
"iss": "rxthinkcmf",
"iat": 1727000000,
"exp": 1727604800
}
```
同一登录签发的两个令牌共享同一个 `jti`(UUID),Redis 键为 `refresh:{uid}:{jti}`。
## 登录流程详解
```text
POST /api/login
{ "username": "admin", "password": "123456", "code": "a3Bx", "key": "xxx" }
{ "username": "admin", "password": "123456", "key": "captcha-uuid", "code": "a3Bx" }
│
▼
1. CaptchaService::check($code, $key)
│ 验证码错误 → {"code":1, "msg":"验证码错误"}
1. LoginRequest(FormRequest)参数格式校验
│ 失败 → {"code":1, "msg":"参数验证失败", "data":{字段=>错误}}
▼
2. User::withoutGlobalScope(['soft_delete'])->where('username', $username)->find()
│ 不存在 → {"code":1, "msg":"用户名或密码错误"}
2. CaptchaService::verify($key, $code) ← 仅当 CAPTCHA_LENGTH > 0 时启用
│ 失败 → {"code":1, "msg":"验证码错误"}
▼
3. $user->status != 1
│ 禁用 → {"code":1, "msg":"账号已被禁用"}
3. JwtService::login($username, $password)
├─ User 查询(软删除范围内)
│ 不存在 → BizException("用户名或密码错误") ← 统一提示,不暴露账号存在性
├─ $user->status 禁用 → BizException("账号已被禁用")
├─ PasswordService::verify(密码, hash, salt)
│ 不匹配 → BizException("用户名或密码错误")
└─ createTokenPair():签发双令牌 + refresh 存 Redis + 加载权限缓存
▼
4. PasswordService::verify($password, $user->password, $user->salt)
│ 不匹配 → {"code":1, "msg":"用户名或密码错误"}
4. AuthController 写登录日志(成功/失败均记录)
▼
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": "..." }
}
}
5. 返回 Result::success($result, '登录成功', true) ← raw,跳过驼峰转换
data = { access_token, refresh_token, token_type, expires_in, user }
(snake_case,与前端 store 的 data.access_token 读取方式一致)
```
:::tip 安全设计
- 用户不存在和密码错误统一提示"用户名或密码错误",避免暴露账号是否存在
- 验证码校验在密码校验之前,防止暴力破解
- 已删除用户通过 `withoutGlobalScope` 查询但不返回,统一走"用户名或密码错误"
- 验证码校验在密码校验之前,防止暴力破解(配合 IP 限流)
- `key` / `code` 参数名与 `/api/captcha` 响应字段一致
:::
## Token 解析流程(AuthMiddleware)
## Token 解析流程(Authenticate 中间件)
```text
请求进入 AuthMiddleware
请求进入 Authenticate(auth.jwt)
│
▼
1. 检查排除路由(login/captcha/oauth2 等直接放行)
│
▼
2. Jwt::getTokenFromHeader()
│ 从 Authorization 头提取 Token
│ 空 → {"code":401, "msg":"未提供认证令牌"}
1. 提取 Authorization 头(带 Bearer 前缀则剥离,裸 token 亦可)
│ 头缺失 → HTTP 200 {"code":401, "msg":"未提供认证令牌"}
▼
3. Jwt::decode($token)
│ 过期 → {"code":401, "msg":"token已过期"}
│ 签名无效 → {"code":401, "msg":"token签名无效"}
2. JwtService::parseToken($token)
│ 过期/签名无效 → HTTP 200(消息取自异常,如"Expired token")
│ type !== 'access' → BizException("无效的access_token") → HTTP 200
▼
4. 校验 $decoded->type === 'access'
│ 非 access → {"code":401, "msg":"无效的认证令牌"}
3. 注入 attributes['userInfo'] = {uid, username, jti, ...}
▼
5. $request->userInfo = $decoded
│ 注入用户信息(uid、username 等)
4. 读取控制器方法的 #[Permission] 注解 → checkPermission()
│ 无注解 → 跳过权限校验
│ uid=1 → 超级管理员直接放行
│ 无权限 → HTTP 403 {"code":403, "msg":"无访问权限:sys:xxx:xxx"}
▼
6. 读取 #[Permission] 注解 → 校验权限
│ 无权限 → {"code":403, "msg":"无访问权限"}
▼
7. 放行 → $next($request)
5. 放行 → $next($request)
```
(完整中间件链见 [4.2 请求处理链路](/zh/architecture/request-flow.html))
## Token 刷新流程
```text
POST /api/oauth2/token
{ "grant_type": "refresh_token", "refresh_token": "eyJ..." }
POST /api/refresh
{ "refresh_token": "eyJ..." }
│
▼
1. Jwt::decode($refreshToken)
│ 过期/无效 → {"code":1, "msg":"无效的refresh_token"}
1. JwtService::refreshToken($refreshToken)
├─ 解析失败 → BizException("无效的refresh_token")
├─ type !== 'refresh' → BizException("无效的refresh_token")
├─ Lua 脚本原子校验 Redis 中 refresh:{uid}:{jti} 并删除(一次性)
│ 不存在/已用 → BizException("refresh_token已失效")
├─ 用户不存在或禁用 → BizException("用户不存在或已被禁用")
└─ createTokenPair():新 jti 签发新令牌对,写入 Redis
▼
2. 校验 $decoded->type === 'refresh'
│ 非 refresh → {"code":1, "msg":"无效的refresh_token"}
▼
3. User::find($decoded->uid)
│ 不存在或禁用 → {"code":1, "msg":"用户不存在或已被禁用"}
▼
4. 签发新 access_token + 新 refresh_token
│
▼
5. 返回新令牌对
2. 返回 Result::success($result, '刷新成功', true) ← raw,snake_case
data = { access_token, refresh_token, token_type, expires_in }
```
:::tip "刷新即换新"策略
刷新时旧 refresh_token 不显式作废,新旧令牌在各自有效期内都可用。如需一次性刷新(旧令牌刷新后立即失效),应结合 Redis 黑名单机制。
:::tip 两种刷新入口
- `POST /api/refresh`(推荐):公开接口,请求体 `{refresh_token}`;
- `POST /api/oauth2/token`:OAuth2 兼容,`grant_type=refresh_token`。
两者最终都调用 `JwtService::refreshToken()`,旧 refresh_token 一次性作废。
:::
:::warning Redis 降级行为
Redis 不可用时刷新接口**跳过 jti 校验**(降级放行),保证可用性;此时踢下线/一次性刷新能力失效。生产环境应保证 Redis 高可用。
:::
## 前端集成
### 登录与存储
### 登录与存储(摘自 `ui/src/store/modules/user.ts` 实际代码)
```typescript
// 登录
const response = await login({ username, password, code, key });
localStorage.setItem('access_token', response.data.accessToken);
localStorage.setItem('refresh_token', response.data.refreshToken);
// 登录(key/code 为验证码参数),响应 data 为 snake_case
const response = await login({ username, password, key, code });
const { code, data } = response;
if (code === ResultEnum.SUCCESS) {
const ex = 7 * 24 * 60 * 60 * 1000;
storage.set(ACCESS_TOKEN, data.access_token, ex); // 仅存 access_token
}
```
### 请求拦截器(自动携带 Token)
### 请求拦截器(自动携带 Token,摘自 `ui/src/utils/http/axios/index.ts`)
```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;
});
const token = storage.get('ACCESS-TOKEN', '');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
```
### 响应拦截器(自动刷新 Token)
### 响应拦截器(401 处理,摘自 `ui/src/utils/http/axios/index.ts`)
:::info 本项目未内置自动续签
401 时直接提示重新登录(`POST /api/refresh` 能力保留给第三方客户端与后续升级)。
:::
```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);
}
);
// code === 401(ResultEnum.TIMEOUT)
case ResultEnum.TIMEOUT:
if (router.currentRoute.value?.name === PageEnum.BASE_LOGIN_NAME) return;
ElMessageBox.confirm('登录身份已失效,请重新登录!', '提示', { type: 'warning' })
.then(() => {
storage.clear();
window.location.href = PageEnum.BASE_LOGIN; // '/login'
})
.catch(() => {});
break;
```
## 安全建议
| 建议 | 说明 |
|------|------|
| 生产环境修改密钥 | `JWT.SECRET` 使用 32 字节以上随机字符串 |
| 生产环境修改密钥 | `JWT_SECRET` ≥ 32 字符随机值,**不满足应用拒绝启动** |
| HTTPS 传输 | Token 明文传输,必须使用 HTTPS |
| 合理设置有效期 | access_token 不宜过长(2小时),refresh_token 按需调整 |
| 合理设置有效期 | access_token 不宜过长(2 小时),refresh_token 按需调整 |
| 敏感操作二次验证 | 修改密码、删除数据等操作建议额外校验 |
| Token 黑名单 | 生产环境建议用 Redis 实现 Token 吊销(当前为简化实现) |
| 依赖 Redis 有状态能力 | 登出吊销、踢下线、一次性刷新均依赖 Redis,生产保证可用 |
| 前端安全存储 | 避免将 Token 存入 Cookie(防 CSRF),推荐 localStorage |
| 改密后全端失效 | 修改密码时调用 `revokeAllTokens($userId)` 清除全部令牌 |
## 错误响应汇总
| 场景 | 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 | 用户不存在或已被禁用 |
| Token 过期 | 200 | 401 | Expired token(消息取自异常) |
| Token 签名无效 | 200 | 401 | Signature verification failed |
| Token 类型错误(refresh 当 access 用) | 200 | 401 | 无效的access_token |
| 无权限 | **403** | 403 | 无访问权限:{权限码} |
| refresh_token 无效/类型错 | 200 | 1 | 无效的refresh_token |
| refresh_token 已用/已吊销 | 200 | 1 | refresh_token已失效 |
| 用户被禁用(刷新时) | 200 | 1 | 用户不存在或已被禁用 |
| 登录密码错误 | 200 | 1 | 用户名或密码错误 |
:::tip 状态码约定
`Authenticate` 中间件的 401 以 HTTP 200 + body code 表达(前端据 code=401 跳转登录),403 **同时设置 HTTP 状态码**(便于网关处理),业务层异常走 HTTP 200 + body code。详见 [5.16 统一响应与异常抛出](/zh/core/exception.html)。
:::
+119 -117
View File
@@ -1,7 +1,7 @@
# 5.15 登录日志
:::tip 概述
登录日志记录用户的登录/登出行为,包括成功和失败记录。每次登录尝试都会记录,便于安全审计和异常行为分析。与操作日志不同,登录日志由 LoginLogic 手动记录,而非中间件自动记录。
登录日志记录用户的登录/登出行为,**成功与失败均记录**,便于安全审计和异常行为分析。与操作日志不同,登录日志由 `AuthController` 手动记录(`writeLoginLog`),而非中间件自动记录;登录日志的 `param` 只记录用户名,不涉及密码。
:::
## 记录流程
@@ -10,38 +10,41 @@
用户提交登录
│
▼
LoginLogic::login()
AuthController::login(LoginRequest)
│
├─ 创建 RequestInfoService(解析 IP、UA、归属地)
├─ LoginRequest 参数格式校验
│
├─ 验证验证码 CaptchaService::check()
│ └─ 失败 → 记录登录日志(status=0, error="验证码错误")
├─ CaptchaService::verify(key, code)
│ └─ 失败 → 写登录日志(status=1 失败, error="验证码错误")→ 返回失败
│
├─ 调用 JwtService::login()
│ ├─ 查询用户 → 不存在 → 记录登录日志(status=0, error="用户名或密码错误")
│ ├─ 校验状态 → 禁用 → 记录登录日志(status=0, error="账号已被禁用")
│ ├─ 校验密码 → 错误 → 记录登录日志(status=0, error="用户名或密码错误")
├─ JwtService::login()
│ ├─ 查询用户 → 不存在 → BizException → 写登录日志(失败, "用户名或密码错误")
│ ├─ 校验状态 → 禁用 → BizException → 写登录日志(失败, "账号已被禁用")
│ ├─ 校验密码 → 错误 → BizException → 写登录日志(失败, "用户名或密码错误")
│ └─ 签发 Token → 成功
│
├─ 记录登录日志(status=1, 无错误)
├─ 写登录日志(status=0 成功,含耗时/归属地/浏览器)
│
└─ 返回 { access_token, refresh_token, user }
└─ 返回 { access_token, refresh_token, token_type, expires_in, user } ← raw snake_case
```
:::tip 双写保险
无论走成功分支还是异常分支,`AuthController` 都会调用 `writeLoginLog`——**登录尝试必留痕**(含失败原因),这是登录安全审计的基础。
:::
### 登录/登出记录
```text
┌─────────────────────────────────────────────────────────────┐
│ 登录成功 │
│ LoginLogic::login() → 成功 → recordLoginLog(username, 1, 0) │
│ LoginController::login() → 成功日志 │
│ AuthController::login() 成功 → writeLoginLog(成功) │
├─────────────────────────────────────────────────────────────┤
│ 登录失败 │
│ LoginLogic::login() → 异常 → recordLoginLog(username, 0, 1) │
│ LoginController::login() → 失败日志 │
│ 登录失败(验证码/密码/状态任一失败) │
│ AuthController::login() 异常 → writeLoginLog(失败 + error) │
├─────────────────────────────────────────────────────────────┤
│ 登出 │
│ LoginController::logout() → recordLoginLog(username, 2, 0) │
│ AuthController::logout() → revokeToken() 吊销令牌 │
│ → LoginLog::create(log_type=2) │
└─────────────────────────────────────────────────────────────┘
```
@@ -50,94 +53,102 @@ LoginLogic::login()
| 字段 | 类型 | 说明 | 示例 |
|------|------|------|------|
| `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 |
| `log_type` | TINYINT | 类型:1=登录 2=登出 | 1 |
| `source` | TINYINT | 来源端:0=后台 1=前台 | 0 |
| `method` | VARCHAR(10) | HTTP 方法 | POST |
| `request_method` | VARCHAR(50) | 入口标识 | login |
| `url` | VARCHAR(255) | 请求 URL | /api/login |
| `param` | TEXT | 请求参数——**只记录用户名**(JSON) | {"username":"admin"} |
| `result` | TEXT | 结果(JSON:code + msg) | {"code":0,"msg":"登录成功"} |
| `status` | TINYINT | 状态:0=成功 1=失败 | 0 |
| `error` | VARCHAR(200) | 错误信息(仅失败时写入) | 用户名或密码错误 |
| `ip` | VARCHAR(50) | 客户端 IP(经 ProxyMiddleware 还原) | 192.168.1.100 |
| `location` | VARCHAR(100) | IP 归属地(ip2region) | 江苏省南京市 |
| `os` | VARCHAR(100) | 操作系统 | Windows 11 |
| `browser` | VARCHAR(100) | 浏览器 | Chrome 128 |
| `consume_time` | INT | 登录耗时(毫秒) | 260 |
| `create_time` | DATETIME | 登录时间 | 2026-10-02 10:30:00 |
:::tip 耗时字段的用途
登录耗时异常增大可能是 bcrypt 暴力破解尝试、数据库慢查询或 Redis 阻塞的信号——安全巡检时可按 `consume_time` 排序排查。
:::
## 核心代码
### 登录记录
### 登录记录(AuthController)
```php
// app/logic/LoginLogic.php
// app/Http/Controllers/Api/AuthController.php(节选)
public function login(string $username, string $password, string $captchaKey, string $captchaCode): array
public function login(LoginRequest $request): JsonResponse
{
$requestInfo = RequestInfoService::create(request());
// ... 参数与验证码校验 ...
$startTime = microtime(true);
try {
// 验证验证码
if (!CaptchaService::check($captchaKey, $captchaCode)) {
throw new \Exception('验证码错误');
}
// 调用 JwtService 登录
$result = $this->jwtService->login($username, $password);
$this->writeLoginLog($request, $username, 'login', true, '登录成功', $startTime);
// 记录成功日志
$this->recordLoginLog($username, 1, 0, '', '登录成功');
return $result;
return $this->success($result, '登录成功');
} catch (\Exception $e) {
// 记录失败日志
$this->recordLoginLog($username, 1, 1, $e->getMessage(), '登录失败');
$this->writeLoginLog($request, $username, 'login', false, $e->getMessage(), $startTime);
throw $e;
return $this->fail($e->getMessage());
}
}
private function writeLoginLog(Request $request, string $username, string $entry,
bool $success, string $message, float $startTime): void
{
$duration = round((microtime(true) - $startTime) * 1000);
$requestInfo = RequestInfoService::create($request);
$data = [
'username' => $username,
'log_type' => 1, // 1=登录
'source' => 0, // 0=后台
'method' => $request->method(),
'request_method' => $entry, // login 等入口
'url' => $request->fullUrl(),
'param' => json_encode(['username' => $username], JSON_UNESCAPED_UNICODE), // 只记录用户名
'result' => json_encode(['code' => $success ? 0 : 1, 'msg' => $message], JSON_UNESCAPED_UNICODE),
'ip' => $requestInfo->getIp(),
'location' => $requestInfo->getIpLocation(),
'os' => $requestInfo->getOs(),
'browser' => $requestInfo->getBrowser(),
'consume_time' => $duration,
'status' => $success ? 0 : 1, // 0=成功 1=失败
'create_time' => now(),
];
if (! $success) {
$data['error'] = $message;
}
LoginLog::create($data);
}
```
### 统一记录方法
### 登出记录(AuthController::logout)
```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
public function logout(Request $request): JsonResponse
{
$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();
$token = $request->bearerToken();
if ($token) {
$this->logic->recordLoginLog($this->getLoginUsername(), 2, 0, '', '退出成功');
$this->jwtService->revokeToken($token); // 吊销 refresh_token(即时失效)
$requestInfo = RequestInfoService::create($request);
$userInfo = $request->attributes->get('userInfo');
// 登出日志:log_type=2
LoginLog::create([
'username' => $userInfo->username ?? '',
'log_type' => 2,
'source' => 0,
// ... 其余字段同登录记录 ...
]);
}
return $this->success(null, '登出成功');
}
```
@@ -146,10 +157,10 @@ public function logout(): Json
| 方法 | 路径 | 权限码 | 说明 |
|------|------|--------|------|
| 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 | 批量删除 |
| GET | `/api/login/log/page` | sys:loginLog:page | 分页列表 |
| GET | `/api/login/log/detail/{id}` | sys:loginLog:detail | 详情 |
| DELETE | `/api/login/log/delete/{id}` | sys:loginLog:delete | 删除 |
| DELETE | `/api/login/log/batchDelete` | sys:loginLog:batchDelete | 批量删除 |
## 安全分析
@@ -158,8 +169,8 @@ public function logout(): Json
```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
FROM laravel_login_log
WHERE status = 1
AND create_time > DATE_SUB(NOW(), INTERVAL 5 MINUTE)
GROUP BY ip
HAVING fail_count >= 10
@@ -171,33 +182,21 @@ ORDER BY fail_count DESC;
```sql
-- 同一账号短时间内大量失败(可能被暴力破解)
SELECT username, COUNT(*) as fail_count
FROM think_login_log
WHERE status = 0
FROM laravel_login_log
WHERE status = 1
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
FROM laravel_login_log
WHERE status = 1
AND create_time > DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY ip, location
ORDER BY fail_count DESC
@@ -211,25 +210,29 @@ LIMIT 20;
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
SUM(CASE WHEN status = 0 THEN 1 ELSE 0 END) as success,
ROUND(SUM(CASE WHEN status = 0 THEN 1 ELSE 0 END) / COUNT(*) * 100, 2) as success_rate
FROM laravel_login_log
WHERE log_type = 1
AND create_time > DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY DATE(create_time)
ORDER BY date;
```
:::tip status 语义提醒
本项目 `status`:**0=成功,1=失败**(与操作日志一致)。上面的检测 SQL 已按此约定编写。
:::
## 与操作日志的区别
| 维度 | 登录日志 | 操作日志 |
|------|---------|---------|
| 表 | `think_login_log` | `think_operation_log` |
| 记录方 | LoginLogic 手动记录 | LogMiddleware 自动记录 |
| 记录内容 | 仅登录/登出 | 所有写操作 + 导出/导入 |
| 触发方式 | 代码中调用 `recordLoginLog()` | `#[Log]` 注解 |
| 表 | `laravel_login_log` | `laravel_operation_log` |
| 记录方 | `AuthController` 手动记录 | `LogOperation` 中间件(terminate 落库) |
| 记录内容 | 仅登录/登出 | 写操作 + 导出/导入/下载 |
| 触发方式 | 代码中 `writeLoginLog()` | `#[OperationLog]` 注解 |
| 用途 | 登录安全审计 | 业务操作审计 |
| 失败也记录 | ✓(登录失败必须记录) | ✗(仅记录成功的写操作) |
| 失败也记录 | ✓(登录失败必须记录) | ✓(响应失败状态也记录) |
## 日志保留策略
@@ -240,7 +243,7 @@ ORDER BY date;
```sql
-- 示例:清理 180 天前的登录日志
DELETE FROM think_login_log WHERE create_time < DATE_SUB(NOW(), INTERVAL 180 DAY);
DELETE FROM laravel_login_log WHERE create_time < DATE_SUB(NOW(), INTERVAL 180 DAY);
```
## 最佳实践
@@ -251,5 +254,4 @@ DELETE FROM think_login_log WHERE create_time < DATE_SUB(NOW(), INTERVAL 180 DAY
| 统一错误提示 | "用户名或密码错误"不区分用户不存在和密码错误 |
| 定期分析 | 定期检查异常登录模式(暴力破解、异地登录) |
| IP 黑名单 | 将高频恶意 IP 加入黑名单(Nginx/防火墙层) |
| 配合验证码 | 验证码防刷 + 登录日志分析形成双重防护 |
| 日志不可篡改 | 登录日志仅 INSERT,无 UPDATE 接口 |
| 配合验证码 | 验证码防刷 + 登录日志分析形成双重防护(配合 IP 限流,见 [5.3 验证码机制](/zh/core/captcha.html)) |
@@ -1,7 +1,7 @@
# 5.14 操作日志
:::tip 概述
操作日志通过 `#[Log]` 注解 + LogMiddleware 自动记录,无需手动埋点。所有写操作(POST/PUT/DELETE/PATCH)始终记录,GET 请求仅当注解类型为导出/下载/导入时记录。日志写入失败不影响主业务流程。
操作日志通过 `#[OperationLog]` 注解 + `LogOperation` 中间件自动记录,无需手动埋点。**`handle` 阶段只采集不落库,`terminate` 阶段在响应完成后写入**(不阻塞业务);所有写操作(POST/PUT/DELETE/PATCH)始终记录,GET 请求仅当注解类型为导出/下载/导入时记录。日志写入失败静默处理,不影响主业务流程。
:::
## 记录流程
@@ -10,60 +10,66 @@
请求进入
│
▼
LogMiddleware::handle()
LogOperation::handle()
│
├─ 记录开始时间 microtime(true)
├─ 反射读取 #[OperationLog] 注解(ReflectionMethod,微秒级)
│
├─ 执行后续中间件和控制器($next($request))
│
├─ 获取响应
│
├─ 检查排除路由(config middleware.log_except)
│ └─ 命中 → 直接返回,不记录
│
├─ 判断是否需要记录
├─ 判断是否需要记录(determineIfShouldLog)
│ ├─ POST/PUT/DELETE/PATCH → 始终记录 ✓
│ ├─ GET + #[Log] TYPE_EXPORT → 记录 ✓
│ ├─ GET + #[Log] TYPE_DOWNLOAD → 记录 ✓
│ ├─ GET + #[Log] TYPE_IMPORT → 记录 ✓
│ └─ 其它 GET → 不记录 ✗
│ ├─ GET + 注解类型为 TYPE_EXPORT(6)/TYPE_DOWNLOAD(16)/TYPE_IMPORT(5) → 记录 ✓
│ └─ 其它 GET / OPTIONS / HEAD → 不记录 ✗
│
├─ 读取 #[Log] 注解(AttributeService::getLog)
│ ├─ 有注解 → 使用注解的 title、type、description
│ └─ 无注解 → 使用路径作为 title,按 HTTP 方法猜测 type
├─ 无需记录 → 直接放行
│
├─ 组装日志数据
│ ├─ 标题、类型(来自注解)
│ ├─ URL、请求参数(截取前 2000 字符)
│ ├─ 响应内容(截取前 2000 字符)
│ ├─ IP、归属地、操作系统、浏览器(RequestInfoService)
│ ├─ 执行耗时 = (当前时间 - 开始时间) × 1000 毫秒
│ └─ 状态(检测响应中的 code:0=正常 1=异常)、错误信息
├─ 收集基础上下文(method、url、param、ip)
│ param = JSON(排除 password/token,截取前 2000 字符)
│
└─ 写入 OperationLog 表(try-catch 包裹,失败不影响主业务)
├─ 注入 title / type(注解优先;无注解时 title 回退请求路径、
│ type 按 HTTP 方法推断:POST→新增 PUT→修改 DELETE→删除)
│
├─ 预解析 RequestInfoService(IP 归属地/OS/浏览器,写入上下文)
│ (handle 阶段 request 信息完整,terminate 阶段可能已丢失)
│
├─ 上下文挂载到 request attributes(键 operation_log.context)
│ (handle 与 terminate 由框架以不同实例执行,实例状态无法跨阶段传递)
│
└─ $next($request) 继续执行业务
│
▼ 响应发送完成后
LogOperation::terminate()
│
├─ 从 request attributes 读取上下文,不存在(无需记录)→ 直接返回
│
├─ 补充响应内容 result(截取前 2000 字符)
├─ 补充 consume_time(基于 LARAVEL_START 计算,毫秒)
├─ 补充 status(检测响应 code:0/200=正常 0,其余=异常 1)
├─ 补充 error(异常时取响应中的 msg)
├─ 补充 create_user(来自 Authenticate 注入的 userInfo.username)
│
└─ OperationLog::create(...) (try-catch 包裹,失败静默不影响业务)
```
## 记录字段
| 字段 | 说明 | 来源 | 示例 |
|------|------|------|------|
| `title` | 操作标题 | `#[Log]` 注解 title 参数 | 用户管理-新增记录 |
| `type` | 操作类型 | `#[Log]` 注解 type 参数 | 1=新增 |
| `source` | 来源 | 固定 0 | 0=后台 |
| `source` | 来源端 | 固定 0(0=后台 1=前台) | 0 |
| `title` | 操作标题 | `#[OperationLog]` 注解 title 参数,无注解回退请求路径 | 用户管理-新增 |
| `type` | 操作类型 | 注解 type 参数(默认按 HTTP 方法推断) | 1=新增 |
| `method` | HTTP 方法 | 请求方法 | POST |
| `request_method` | 控制器方法名 | 请求 action | add |
| `url` | 请求 URL | 请求 url | /api/user/add |
| `param` | 请求参数 | JSON 截取前 2000 字符 | {"username":"admin"} |
| `request_method` | 控制器方法名 | 路由 action | add |
| `url` | 请求路径(不含域名) | `fullUrl` 截取路径部分 | /api/user/add |
| `param` | 请求参数 | JSON **排除 password/token**,截取前 2000 字符 | {"username":"admin"} |
| `result` | 响应内容 | JSON 截取前 2000 字符 | {"code":0,"msg":"添加成功"} |
| `ip` | 客户端 IP | RequestInfoService | 192.168.1.100 |
| `ip` | 客户端 IP | RequestInfoService(经 ProxyMiddleware 还原) | 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(异常时) | 用户名已存在 |
| `os` | 操作系统 | RequestInfoService | Windows 11 |
| `browser` | 浏览器 | RequestInfoService | Chrome 128 |
| `consume_time` | 执行耗时 | 毫秒(`LARAVEL_START` 起算) | 125 |
| `status` | 状态 | 响应 code 判断(0/200=0,其余=1) | 0=正常 1=异常 |
| `error` | 错误信息 | 响应中的 msg(异常时) | 名称已存在 |
| `create_user` | 操作人 | 当前登录用户名 | admin |
| `create_time` | 操作时间 | 自动填充 | 2026-09-22 10:30:00 |
| `create_time` | 操作时间 | terminate 阶段写入 | 2026-10-02 10:30:00 |
## 注解使用
@@ -71,190 +77,178 @@ LogMiddleware::handle()
```php
// 写操作(POST/PUT/DELETE/PATCH)始终记录
#[Log('用户管理-新增', Log::TYPE_ADD)]
public function add(): Json { ... }
#[OperationLog('用户管理-新增', OperationLog::TYPE_ADD)]
public function add(): JsonResponse { ... }
#[Log('用户管理-修改', Log::TYPE_UPDATE)]
public function update(): Json { ... }
#[OperationLog('用户管理-修改', OperationLog::TYPE_UPDATE)]
public function update(): JsonResponse { ... }
#[Log('用户管理-删除', Log::TYPE_DELETE)]
public function delete(int $id): Json { ... }
#[OperationLog('用户管理-删除', OperationLog::TYPE_DELETE)]
public function delete(int $id): JsonResponse { ... }
```
### 带描述占位符
```php
#[Log('用户管理-删除记录', Log::TYPE_DELETE, '删除用户ID:{id}')]
public function delete(int $id): Json { ... }
#[OperationLog('用户管理-删除', OperationLog::TYPE_DELETE, '删除用户ID:{id}')]
public function delete(int $id): JsonResponse { ... }
// 占位符 {id} 会被替换为 $request->param('id') 的值
// 占位符 {id} 会被替换为请求参数中 id 的值
// 实际记录:"删除用户ID:42"
#[Log('用户管理-新增记录', Log::TYPE_ADD, '新增用户:{username}')]
public function add(): Json { ... }
#[OperationLog('用户管理-新增', OperationLog::TYPE_ADD, '新增用户:{username}')]
public function add(UserRequest $request): JsonResponse { ... }
// 实际记录:"新增用户:admin"
#[Log('用户管理-批量删除', Log::TYPE_DELETE, '批量删除用户,ID列表:{ids}')]
public function batchDelete(): Json { ... }
// 实际记录:"批量删除用户,ID列表:[1,2,3]"
#[OperationLog('用户管理-批量删除', OperationLog::TYPE_DELETE)]
public function batchDelete(): JsonResponse { ... }
```
### 导出操作(GET 也会记录)
```php
#[Log('用户管理-导出数据', Log::TYPE_EXPORT)]
public function export(): Json { ... }
#[OperationLog('用户管理-导出数据', OperationLog::TYPE_EXPORT)]
public function export(): JsonResponse { ... }
#[Log('用户管理-导入数据', Log::TYPE_IMPORT)]
public function import(): Json { ... }
#[OperationLog('用户管理-导入数据', OperationLog::TYPE_IMPORT, '通过Excel导入用户数据')]
public function import(): JsonResponse { ... }
```
:::warning GET 请求的记录策略
GET 请求仅当注解类型为 `TYPE_EXPORT`、`TYPE_DOWNLOAD` 或 `TYPE_IMPORT` 时才会记录日志。普通查询(`TYPE_QUERY`)不会被记录,以避免大量查询淹没日志。
GET 请求**仅当**注解类型为 `TYPE_IMPORT(5)`、`TYPE_EXPORT(6)`、`TYPE_DOWNLOAD(16)` 时才会记录日志。普通查询(`TYPE_QUERY`)不会被记录,以避免大量查询淹没日志——这也是 `LogOperation::determineIfShouldLog()` 的硬性规则,改注解类型也绕不开。
:::
### 查询类型(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}')]
#[OperationLog('用户管理-新增', OperationLog::TYPE_ADD, '新增用户:{username}')]
#[Permission('sys:user:add', '添加用户')]
public function add(): Json { ... }
public function add(UserRequest $request): JsonResponse { ... }
// 执行顺序:
// 1. AuthMiddleware 校验权限(#[Permission])
// 1. Authenticate 校验权限(#[Permission]),失败返回 403(该次请求不产生日志)
// 2. 权限通过后进入 Controller
// 3. LogMiddleware 记录日志(#[Log])
// 3. LogOperation terminate 阶段记录日志(#[OperationLog])
```
## 操作类型常量
`app/Attributes/OperationLog.php` 共定义 **30 种**操作类型常量:
| 常量 | 值 | 说明 | 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 | 其他 | — |
| `TYPE_ADD` | 1 | 新增 | — |
| `TYPE_UPDATE` | 2 | 修改 | — |
| `TYPE_DELETE` | 3 | 删除 | — |
| `TYPE_QUERY` | 4 | 查询 | ✗ |
| `TYPE_IMPORT` | 5 | 导入 | ✓ |
| `TYPE_EXPORT` | 6 | 导出 | ✓ |
| `TYPE_APPROVE` | 7 | 审批 | — |
| `TYPE_REJECT` | 8 | 驳回 | — |
| `TYPE_SUBMIT` | 9 | 提交 | — |
| `TYPE_WITHDRAW` | 10 | 撤回 | — |
| `TYPE_CLOSE` | 11 | 关闭 | — |
| `TYPE_OPEN` | 12 | 开启 | — |
| `TYPE_ENABLE` | 13 | 启用 | — |
| `TYPE_DISABLE` | 14 | 禁用 | — |
| `TYPE_UPLOAD` | 15 | 上传 | — |
| `TYPE_DOWNLOAD` | 16 | 下载 | ✓ |
| `TYPE_SEND` | 17 | 发送 | — |
| `TYPE_SYNC` | 18 | 同步 | — |
| `TYPE_GENERATE` | 19 | 生成 | — |
| `TYPE_CLEAR` | 20 | 清理(如清缓存) | — |
| `TYPE_RESTORE` | 21 | 恢复 | — |
| `TYPE_LOGIN` | 22 | 登录 | — |
| `TYPE_LOGOUT` | 23 | 登出 | — |
| `TYPE_RESET` | 24 | 重置 | — |
| `TYPE_ASSIGN` | 25 | 分配 | — |
| `TYPE_AUTHORIZE` | 26 | 授权 | — |
| `TYPE_EXECUTE` | 27 | 执行 | — |
| `TYPE_PAUSE` | 28 | 暂停 | — |
| `TYPE_RESUME` | 29 | 恢复执行 | — |
| `TYPE_OTHER` | 99 | 其他 | — |
完整常量列表见 `app/attribute/Log.php`(共 40 种)。
(`type` 未指定时默认 0,由中间件按 HTTP 方法推断。)
## 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', // 获取用户信息
],
```
| GET | `/api/oper/log/page` | sys:operLog:page | 分页列表 |
| GET | `/api/oper/log/detail/{id}` | sys:operLog:detail | 详情 |
| DELETE | `/api/oper/log/delete/{id}` | sys:operLog:delete | 删除 |
| DELETE | `/api/oper/log/batchDelete` | sys:operLog:batchDelete | 批量删除 |
## 核心代码
```php
// app/middleware/LogMiddleware.php
// app/Http/Middleware/LogOperation.php(节选)
public function handle(Request $request, \Closure $next): Response
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;
}
$logAttr = $this->getLogAttribute($request); // 反射读取注解
if (! $this->determineIfShouldLog($request->method(), $logAttr)) {
return $next($request);
}
$method = strtoupper($request->method());
$context = [
'source' => 0,
'method' => strtoupper($request->method()),
'request_method' => $request->route()?->getActionMethod() ?? '',
'url' => parse_url($request->fullUrl(), PHP_URL_PATH) ?: '/',
'param' => mb_substr(json_encode($request->except(['password', 'token']), JSON_UNESCAPED_UNICODE), 0, 2000),
'ip' => $request->ip(),
'title' => $logAttr?->title ?: $request->path(), // 无注解回退请求路径
'type' => $logAttr?->type ?: $this->guessType($request->method()), // 无注解按方法推断
];
// 获取注解信息
$controllerClass = 'app\\controller\\' . $request->controller();
$logAttr = AttributeService::getLog($controllerClass, $request->action());
// 请求信息在响应发出前最完整,handle 阶段预解析并写入上下文
$requestInfo = RequestInfoService::create($request);
$context['location'] = $requestInfo->getIpLocation();
$context['os'] = $requestInfo->getOs();
$context['browser'] = $requestInfo->getBrowser();
// 判断是否需要记录
$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 仅记录导出/下载/导入
}
// 上下文挂载到请求属性(handle 与 terminate 由框架以不同实例执行,
// 实例状态无法跨阶段传递,必须挂在请求上)
$request->attributes->set(self::CONTEXT_KEY, $context);
return $next($request);
}
public function terminate(Request $request, Response $response): void
{
$context = $request->attributes->get(self::CONTEXT_KEY);
if (! is_array($context)) {
return;
}
if (!$needLog) {
return $response;
}
// 取出即删除,防止同一次请求重复写入
$request->attributes->remove(self::CONTEXT_KEY);
try {
$consumeTime = round((microtime(true) - $startTime) * 1000);
$userInfo = $request->userInfo ?? null;
$requestInfo = RequestInfoService::create($request);
$content = $response->getContent();
$statusCode = $this->detectStatus($content);
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 ?? '',
]);
$context['result'] = mb_substr($content ?? '', 0, 2000);
$context['consume_time'] = defined('LARAVEL_START')
? round((microtime(true) - LARAVEL_START) * 1000) : 0;
$context['status'] = $statusCode;
$context['error'] = $statusCode ? ($this->detectError($content) ?? '') : '';
$userInfo = $request->attributes->get('userInfo');
$context['create_user'] = $userInfo->username ?? '';
OperationLog::create($context);
} catch (\Exception $e) {
// 日志记录失败不影响业务
// 日志写入失败不影响业务
}
return $response;
}
```
:::tip 为什么 handle/terminate 分离?
日志采集需要请求阶段的信息(IP、UA、参数),而写入放在响应之后——既拿到了完整上下文,又不占用业务响应时间。这是 Laravel 中间件 `terminable` 的标准用法。
:::
## 日志查询与分析
### 按操作人查询
@@ -273,7 +267,7 @@ 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
GET /api/oper/log/page?startTime=2026-09-01&endTime=2026-10-02
```
### 按状态查询(查异常操作)
@@ -293,10 +287,10 @@ GET /api/oper/log/page?status=1
```sql
-- 示例:清理 90 天前的操作日志
DELETE FROM think_operation_log WHERE create_time < DATE_SUB(NOW(), INTERVAL 90 DAY);
DELETE FROM laravel_operation_log WHERE create_time < DATE_SUB(NOW(), INTERVAL 90 DAY);
-- 示例:按月归档
SELECT * FROM think_operation_log
SELECT * FROM laravel_operation_log
WHERE create_time >= '2026-09-01' AND create_time < '2026-10-01'
INTO OUTFILE '/backup/operation_log_202609.csv';
```
@@ -305,19 +299,18 @@ INTO OUTFILE '/backup/operation_log_202609.csv';
| 维度 | 操作日志 | 登录日志 |
|------|---------|---------|
| 表 | `think_operation_log` | `think_login_log` |
| 记录方 | LogMiddleware 自动记录 | LoginLogic 手动记录 |
| 记录内容 | 所有写操作 + 导出/导入 | 仅登录/登出 |
| 触发方式 | `#[Log]` 注解 | 代码中调用 `recordLoginLog()` |
| 表 | `laravel_operation_log` | `laravel_login_log` |
| 记录方 | `LogOperation` 中间件(terminate 落库) | `AuthController` 手动记录 |
| 记录内容 | 写操作 + 导出/导入/下载 | 仅登录/登出(成功失败均记) |
| 触发方式 | `#[OperationLog]` 注解 | 代码中 `writeLoginLog()` |
| 用途 | 业务操作审计 | 登录安全审计 |
## 最佳实践
| 实践 | 说明 |
|------|------|
| 标题语义化 | 使用"模块-操作"格式,如"用户管理-新增记录" |
| 标题语义化 | 使用"模块-操作"格式,如"用户管理-新增" |
| 描述加关键参数 | 用 `{param}` 占位符记录业务主键,便于追溯 |
| 敏感操作必须标注 | 删除、重置密码、权限变更等操作必须加 `#[Log]` |
| 查询不记录 | 普通查询不加 `#[Log]` 或使用 `TYPE_QUERY`(不记录) |
| 敏感操作必须标注 | 删除、重置密码、权限变更等操作必须加 `#[OperationLog]` |
| 查询不依赖日志 | 普通查询 `TYPE_QUERY` 不会落库,勿以此做查询审计 |
| 定期清理 | 避免日志表无限增长影响性能 |
| 排除高频接口 | 将频繁调用的接口加入 `log_except` |
+33 -21
View File
@@ -1,31 +1,31 @@
# 5.13 系统参数管理
:::tip 概述
ParamService 管理系统级参数,如默认密码、系统名称等,采用两级缓存(请求级 + 持久缓存),大幅减少数据库查询。
ParamService 管理系统级参数(存于 `laravel_param` 表),如默认密码、限流阈值等,采用两级缓存(请求级 + 持久缓存),大幅减少数据库查询。
:::
## 缓存架构
```text
请求级缓存(static::$cache) ← 同一请求内不重复查库
请求级缓存(静态变量) ← 同一请求内不重复查库
│ 未命中
▼
持久缓存(cache(),1小时) ← 跨请求复用
持久缓存(cache(),1小时) ← 跨请求复用(Redis)
│ 未命中
▼
数据库查询(param 表) ← 查询后写入两级缓存
数据库查询(param 表) ← 查询后写入两级缓存
```
## 核心方法
```php
// app/service/ParamService.php
// app/Services/ParamService.php
// 获取参数值(通用)
$value = ParamService::get('DEFAULT_PASSWORD', '123456');
// 获取字符串参数
$value = ParamService::getString('SITE_NAME', 'RXThinkCMF');
$value = ParamService::getString('SITE_NAME', 'My CMS');
// 获取整数参数
$value = ParamService::getInt('MAX_LOGIN_ATTEMPTS', 5);
@@ -33,14 +33,9 @@ $value = ParamService::getInt('MAX_LOGIN_ATTEMPTS', 5);
// 获取布尔参数
$value = ParamService::getBool('ENABLE_SMS', false);
// 获取默认密码(从系统参数读取,默认 '123456')
$password = ParamService::getDefaultPassword();
// 清除指定参数缓存
// 清除指定参数缓存($code 为空时清空全部参数缓存)
ParamService::clearCache('DEFAULT_PASSWORD');
// 清除所有参数缓存
ParamService::clearAllCache();
ParamService::clearCache();
```
## 方法签名
@@ -51,13 +46,14 @@ ParamService::clearAllCache();
| `getString($code, $default)` | 获取字符串 | string |
| `getInt($code, $default)` | 获取整数 | int |
| `getBool($code, $default)` | 获取布尔 | bool |
| `getDefaultPassword()` | 获取默认密码明文 | string |
| `clearCache($code)` | 清除指定缓存 | void |
| `clearAllCache()` | 清除所有缓存 | void |
| `clearCache($code = '')` | 清除指定/全部缓存 | void |
**消费方示例**:`PasswordService::getDefaultPasswordPlain()` 即通过 `ParamService::get('DEFAULT_PASSWORD', '123456')` 读取默认密码(明文方法,切勿写入日志或返回前端)。
## 查询条件
仅查询满足以下条件的参数:
- `status = 1`(启用)
- `is_delete = 0`(未删除)
@@ -67,10 +63,10 @@ ParamService::clearAllCache();
|------|------|--------|------|
| GET | `/api/param/page` | sys:param:page | 分页列表 |
| GET | `/api/param/list` | sys:param:list | 全量列表 |
| GET | `/api/param/detail/:id` | sys:param:detail | 详情 |
| GET | `/api/param/detail/{id}` | sys:param:detail | 详情 |
| POST | `/api/param/add` | sys:param:add | 新增 |
| PUT | `/api/param/update` | sys:param:update | 修改 |
| DELETE | `/api/param/delete/:id` | sys:param:delete | 删除 |
| DELETE | `/api/param/delete/{id}` | sys:param:delete | 删除 |
| DELETE | `/api/param/batchDelete` | sys:param:batchDelete | 批量删除 |
## 与配置管理的区别
@@ -79,11 +75,27 @@ ParamService::clearAllCache();
|------|------------------|-------------------|
| 数据结构 | 独立的键值对 | 分组 + 配置项 |
| 适用场景 | 系统级参数(默认密码、限流阈值) | 网站配置(名称、Logo、SMTP) |
| 读取方式 | ParamService::get('code') | ConfigItemLogic 查询 |
| 缓存 | 两级缓存 | 无特殊缓存 |
| 读取方式 | `ParamService::get('code')` | `ConfigItem` 模型查询或 `/api/config/web/index` |
| 服务封装 | `ParamService`(静态方法 + 缓存) | `ConfigService` |
## 常用系统参数
| 编码 | 说明 | 默认值 |
|------|------|--------|
| `DEFAULT_PASSWORD` | 默认密码 | 123456 |
| `DEFAULT_PASSWORD` | 默认密码(重置密码时使用) | 123456 |
## 缓存管理
修改参数后缓存不会自动失效(参数无 Observer),需要手动刷新:
```bash
# 修改参数后在代码中调用
ParamService::clearCache('DEFAULT_PASSWORD');
# 或重启服务 / 清除应用缓存兜底
php artisan cache:clear
```
:::warning 修改参数后的生效方式
参数写入数据库后,旧值仍可能存在缓存中(TTL 1 小时)。通过后台「参数管理」修改时建议同步调用 `clearCache()`;直接改库时用 `php artisan cache:clear` 兜底。
:::
+52 -78
View File
@@ -1,7 +1,7 @@
# 5.6 密码加密服务
:::tip 概述
PasswordService 基于 bcrypt 实现双重加盐密码加密,全系统的密码加解密均通过本服务完成,包括登录校验、新建用户、重置密码、租户账号创建等场景。
PasswordService 基于 bcrypt 实现**双重加盐**密码加密,全系统的密码加解密均通过本服务完成,包括登录校验、新建用户、重置密码、租户账号创建等场景。
:::
## 加密方案
@@ -43,40 +43,40 @@ password_hash($combined, PASSWORD_BCRYPT, ['cost' => 12])
| 工作因子 (cost) | 计算耗时 | 安全性 | 适用场景 |
|----------------|---------|--------|---------|
| 10 | ~60ms | 中 | 低性能服务器 |
| **12** | **~250ms** | **高** | **本项目默认值** |
| **12** | **~250ms** | **高** | **本项目默认值(`PasswordService::BCRYPT_ROUNDS`)** |
| 14 | ~1s | 很高 | 高安全需求 |
| 16 | ~4s | 极高 | 不推荐(影响用户体验) |
:::tip 工作因子每 +1,计算耗时翻倍
本项目默认 cost=12,约 250ms,在安全与性能之间取得平衡。可在 `PasswordService::BCRYPT_ROUNDS` 常量中调整。
本项目默认 cost=12(对应 `.env` 的 `BCRYPT_ROUNDS=12`),约 250ms,在安全与性能之间取得平衡。
:::
## 核心代码
```php
// app/service/PasswordService.php
// app/Services/PasswordService.php(节选)
class PasswordService
{
const BCRYPT_ROUNDS = 12; // 工作因子(值越大越安全,12 约 250ms)
const SALT_LENGTH = 10; // 外部盐值长度
const int BCRYPT_ROUNDS = 12; // 工作因子(值越大越安全,12 约 250ms)
const int SALT_LENGTH = 10; // 外部盐值长度
/**
* 生成随机盐值
*
* 用 random_bytes 生成安全随机字节,再 base64 编码并截取指定长度,
* 去除 base64 的填充字符 '=',保证盐值可直接入库。
* 截断后的 salt 仅用于拼接密码,不要求可解码。
*/
public static function generateSalt(int $length = self::SALT_LENGTH): string
{
$bytes = random_bytes((int)ceil($length * 3 / 4));
$bytes = random_bytes((int) ceil($length * 3 / 4));
return substr(rtrim(base64_encode($bytes), '='), 0, $length);
}
/**
* 加密密码(外部 salt 双重加盐)
*
* 处理流程:
* 1. 未传 salt 时自动生成;
* 2. 将明文密码与外部盐拼接;
* 3. 用 bcrypt 加密(内部会再生成一层盐)。
@@ -95,10 +95,7 @@ class PasswordService
'cost' => self::BCRYPT_ROUNDS,
]);
return [
'password' => $hashed,
'salt' => $salt,
];
return ['password' => $hashed, 'salt' => $salt];
}
/**
@@ -114,7 +111,7 @@ class PasswordService
}
/**
* 检查密码是否需要重新加密(迁移旧密码)
* 检查密码是否需要重新加密(迁移旧 cost)
*
* 当 BCRYPT_ROUNDS 调整后,可用此方法识别旧哈希,
* 在用户下次登录时透明地重新加密。
@@ -125,17 +122,6 @@ class PasswordService
'cost' => self::BCRYPT_ROUNDS,
]);
}
/**
* 获取默认密码明文
*
* 从系统参数 DEFAULT_PASSWORD 获取,未配置时回退为 123456。
* 注意:本方法返回明文,切勿写入日志或返回给前端。
*/
public static function getDefaultPasswordPlain(): string
{
return ParamService::getDefaultPassword();
}
}
```
@@ -148,14 +134,24 @@ class PasswordService
| `generateSalt($length?)` | 生成随机盐值 | `string`(默认 10 字符) |
| `needsRehash($hashed)` | 检查是否需要重新加密 | `bool` |
| `defaultPassword()` | 获取默认密码的加密结果 | `['password' => '哈希', 'salt' => '盐值']` |
| `getDefaultPasswordPlain()` | 获取默认密码明文 | `string`(默认 '123456') |
| `getDefaultPasswordPlain()` | 获取默认密码明文(取自系统参数 `DEFAULT_PASSWORD`) | `string`(默认 `'123456'`) |
**全局便捷函数**(`app/Helpers/helpers.php`):
```php
$result = encrypt_password('123456');
// → ['password' => '$2y$12$...', 'salt' => 'xK7pMn3qRt']
$isValid = verify_password('123456', $hash, $salt);
// → true / false
```
## 使用场景
### 1. 新增用户
```php
// UserLogic::beforeAdd()
// UserService 钩子中加密初始密码
protected function beforeAdd(array $data): array
{
$password = $data['password'] ?? PasswordService::getDefaultPasswordPlain();
@@ -170,20 +166,19 @@ protected function beforeAdd(array $data): array
```php
// JwtService::login()
if (!PasswordService::verify($password, $user->password, $user->salt)) {
throw new \Exception('用户名或密码错误');
if (! PasswordService::verify($password, $user->password, $user->salt)) {
throw new BizException('用户名或密码错误'); // 统一提示,不暴露账号存在性
}
```
### 3. 重置密码
```php
// UserLogic::resetPassword()
public function resetPassword(int $id, string $newPassword = ''): bool
{
$user = User::find($id);
if (!$user) {
throw new \Exception('用户不存在');
if (! $user) {
throw new BizException('用户不存在');
}
if (empty($newPassword)) {
@@ -193,14 +188,25 @@ public function resetPassword(int $id, string $newPassword = ''): bool
$encrypted = PasswordService::encrypt($newPassword);
$user->password = $encrypted['password'];
$user->salt = $encrypted['salt'];
return $user->save() !== false;
$saved = $user->save();
if ($saved) {
// 改密后全端下线:吊销该用户全部令牌
app(JwtService::class)->revokeAllTokens($id);
}
return $saved !== false;
}
```
:::tip 改密安全闭环
重置密码成功后调用 `JwtService::revokeAllTokens($userId)` —— 删除 Redis 中该用户全部 refresh_token 与权限缓存,**所有已登录设备立即失效**,防止旧密码会话继续使用。见 [5.1 用户认证(JWT)](/zh/core/jwt.html)。
:::
### 4. 租户账号创建
```php
// TenantLogic::createAccount()
$password = $data['password'] ?? PasswordService::getDefaultPasswordPlain();
$encrypted = PasswordService::encrypt($password);
$data['password'] = $encrypted['password'];
@@ -219,26 +225,12 @@ if (PasswordService::needsRehash($user->password)) {
}
```
## 便捷函数
```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 表相关字段
-- laravel_user 表相关字段
password VARCHAR(255) -- bcrypt 哈希值(60字符)
salt VARCHAR(50) -- 外部盐值(10字符)
salt VARCHAR(30) -- 外部盐值(10字符)
-- 示例数据
password: $2y$12$LJ3m9R8s7Kq2vN5pX4wYzeK8vN5pX4wYzeK8vN5pX4wYzeK8vN5pX4
@@ -246,7 +238,7 @@ salt: xK7pMn3qRt
```
:::warning 安全提示
- `password` 和 `salt` 字段在 BaseModel 序列化时自动排除,不会出现在 API 响应中
- `password` / `salt` 是敏感字段:操作日志的 `param` 已排除 `password`、`token`;API 响应也不应返回这两个字段(控制器返回用户数据时注意字段过滤)
- bcrypt 工作因子为 12(约 250ms),暴力破解成本极高
- 即使数据库泄露,也无法还原明文密码
- `getDefaultPasswordPlain()` 返回明文,切勿写入日志或返回给前端
@@ -259,10 +251,10 @@ salt: xK7pMn3qRt
| 双重加盐 | 外部盐 + bcrypt 内部盐,两层保护 |
| 每用户独立盐 | 每个用户的 salt 不同,相同密码产生不同哈希 |
| bcrypt 单向哈希 | 不可逆,无法从哈希还原明文 |
| 工作因子可调 | `BCRYPT_ROUNDS` 常量控制计算强度 |
| 工作因子可调 | `BCRYPT_ROUNDS` 常量控制计算强度(默认 12) |
| 自动迁移 | `needsRehash()` 支持透明升级工作因子 |
| 字段序列化排除 | password/salt 不出现在 API 响应中 |
| 安全随机数 | `random_bytes()` 生成盐值,非伪随机 |
| 改密联动下线 | 配合 `revokeAllTokens()` 全端令牌失效 |
## 性能考量
@@ -270,7 +262,7 @@ salt: xK7pMn3qRt
bcrypt cost=12 的性能特征:
单次加密:~250ms
单次验证:~250ms
并发能力:4 次/秒/CPU 核心
并发能力:约 4 次/秒/CPU 核心
优化建议:
1. 登录验证是唯一需要等待的场景,用户可接受
@@ -279,29 +271,11 @@ bcrypt cost=12 的性能特征:
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);
}
```
| 现象 | 原因 | 处理 |
|------|------|------|
| 重置密码后旧会话仍可用 | 未吊销令牌 | 重置成功后调用 `revokeAllTokens($userId)`(见场景 3) |
| 改了 `BCRYPT_ROUNDS` 后老用户登录异常 | 无影响但哈希未升级 | 用 `needsRehash()` 在登录时透明重加密 |
| 批量导入用户密码登录失败 | 导入时未走 `encrypt()` | 导入逻辑需按 `['password','salt']` 结构加密后入库 |
| 默认密码不是 123456 | 系统参数 `DEFAULT_PASSWORD` 被修改 | 在「参数管理」中查看/调整 |
+125 -138
View File
@@ -1,7 +1,7 @@
# 5.4 权限管理(RBAC)
:::tip 概述
系统采用 RBAC(基于角色的访问控制)权限模型,通过"用户 → 角色 → 菜单/权限"三层关系实现细粒度的权限控制。权限控制分为两个维度:**功能权限**(能做什么操作)和**数据权限**(能看到哪些数据)。
系统采用 RBAC(基于角色的访问控制)权限模型,通过"用户 → 角色 → 菜单/权限"三层关系实现细粒度的权限控制。权限控制分为两个维度:**功能权限**(能做什么操作)和**数据权限**(能看到哪些数据,见 [5.9 数据权限](/zh/core/data-scope.html))。
:::
## 权限模型
@@ -16,7 +16,7 @@
│ username│ │ role_id │ │tenant_id│ │ menu_id │ │ perm │
│ realname│ └────────────┘ │ name │ └────────────┘ │ type │
│ dept_id │ │ code │ │ parent │
│tenant_id│ │data_scope│ │ sort │
│tenant_id│ │ note │ │ sort │
└─────────┘ └─────────┘ └─────────┘
```
@@ -24,35 +24,33 @@
| 关系 | 说明 | 中间表 |
|------|------|--------|
| User ↔ Role | 多对多:一个用户可有多个角色,一个角色可分配给多个用户 | `think_user_role` |
| Role ↔ Menu | 多对多:一个角色可有多个菜单/权限,一个菜单可分配给多个角色 | `think_role_menu` |
| User ↔ Role | 多对多:一个用户可有多个角色,一个角色可分配给多个用户 | `laravel_user_role` |
| Role ↔ Menu | 多对多:一个角色可有多个菜单/权限,一个菜单可分配给多个角色 | `laravel_role_menu` |
| User → Dept | 多对一:一个用户属于一个部门 | `dept_id` 外键 |
| User → Tenant | 多对一:一个用户属于一个租户 | `tenant_id` 外键 |
### 核心表结构
**think_role(角色表):**
**laravel_role(角色表):**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | INT | 主键 |
| `tenant_id` | INT | 租户ID:0=平台级 |
| `tenant_id` | INT | 租户ID:0=平台级(角色按租户隔离,RoleService 继承 BaseTenantService) |
| `name` | VARCHAR(50) | 角色名称 |
| `code` | VARCHAR(50) | 角色编码 |
| `data_scope` | TINYINT | 数据权限范围:1=全部 2=本部门 3=仅本人 |
| `status` | TINYINT | 状态 |
| `remark` | VARCHAR(500) | 备注 |
| `note` | VARCHAR | 备注 |
| `sort` | INT | 排序 |
**think_menu(菜单/权限表):**
**laravel_menu(菜单/权限表):**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | INT | 主键 |
| `parent_id` | INT | 父级 ID(0=顶级) |
| `name` | VARCHAR(50) | 菜单名称 |
| `permission` | VARCHAR(100) | 权限标识(如 `sys:user:add`) |
| `type` | TINYINT | 类型:0=目录 1=菜单 2=按钮 |
| `permission` | VARCHAR(100) | 权限标识(如 `sys:user:add`)——**菜单即权限** |
| `type` | TINYINT | 类型:0=菜单/目录(可导航) 1=权限节点 |
| `path` | VARCHAR(200) | 路由路径 |
| `icon` | VARCHAR(100) | 图标 |
| `sort` | INT | 排序 |
@@ -110,37 +108,46 @@ sys:{模块名}:{操作}
| type | 说明 | 前端行为 | 权限校验 |
|------|------|----------|----------|
| 0 | 目录 | 渲染为侧边栏分组(如"系统管理") | 不参与权限校验 |
| 1 | 菜单 | 渲染为可点击的菜单项(如"用户管理") | 参与权限校验(AuthMiddleware 查询 `type=1`) |
| 2 | 按钮 | 不渲染,用于定义子级权限码 | 权限码挂在 type=1 的父菜单下 |
| 0 | 菜单/目录(可导航) | 渲染为侧边栏分组与菜单项(`getMenus` 只取 type=0) | 不参与权限校验 |
| 1 | 权限节点(按钮/查看等) | 不渲染,仅用于权限校验 | **参与权限校验**(`Authenticate` 查库仅取 `type=1` 且 `permission` 非空) |
### 菜单树示例
```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, 页面, component=system/user/index)
│ ├── 查看用户 (type=1, 权限节点, permission=sys:user:page)
│ ├── 添加用户 (type=1, 权限节点, permission=sys:user:add)
│ ├── 修改用户 (type=1, 权限节点, permission=sys:user:update)
│ ├── 删除用户 (type=1, 权限节点, permission=sys:user:delete)
│ └── 重置密码 (type=1, 权限节点, permission=sys:user:resetPwd)
├── 角色管理 (type=0, 页面, component=system/role/index)
│ ├── 查看角色 (type=1, 权限节点, permission=sys:role:page)
│ ├── 添加角色 (type=1, 权限节点, permission=sys:role:add)
│ └── 修改角色 (type=1, 权限节点, permission=sys:role:update)
└── 菜单管理 (type=0, 页面, component=system/menu/index)
└── 查看菜单 (type=1, 权限节点, permission=sys:menu:list)
内容管理 (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)
├── 文章管理 (type=0, 页面, component=content/article/index)
│ ├── 查看文章 (type=1, 权限节点, permission=sys:article:page)
│ ├── 添加文章 (type=1, 权限节点, permission=sys:article:add)
│ └── 删除文章 (type=1, 权限节点, permission=sys:article:delete)
└── 分类管理 (type=0, 页面, component=content/category/index)
```
:::tip 两类节点各司其职
- **type=0(菜单/目录)**:驱动前端导航(`GET /index/getMenus` 过滤 `type=0, status=0, hide=0, is_delete=0`);
- **type=1(权限节点)**:驱动后端鉴权(`Authenticate` 过滤 `type=1, permission != ''`)。
页面的查看权限(如 `sys:user:page`)也挂在 type=1 节点上。
:::
## 权限校验流程
### 完整流程图
```text
请求进入 AuthMiddleware
请求进入 Authenticate(auth.jwt)
│
▼
1. 从 JWT 获取 uid
@@ -149,53 +156,72 @@ sys:{模块名}:{操作}
2. uid === 1(超级管理员)?
│ 是 → 直接放行
▼
3. 读取 #[Permission] 注解
│ 无注解 → 放行(该接口不需要权限)
3. 读取控制器方法的 #[Permission] 注解(ReflectionMethod)
│ 无注解 → 放行(该接口只需登录)
▼
4. 获取用户角色列表
UserRole::where('user_id', $uid)->column('role_id')
│ 无角色 → return false → 403
4. 三级缓存获取用户权限列表
├─ Redis perms:{uid}(一级)
├─ Laravel Cache perms_fallback:{uid} 300秒(二级)
└─ 查库:UserRole → RoleMenu join Menu
条件:is_delete=0、type=1、permission != ''
(结果回填缓存)
▼
5. 获取角色关联的权限节点
RoleMenu → Menu::whereIn('role_id', $roleIds)
.where('type', 1) // 只查权限节点
.where('is_delete', 0)
.column('permission')
▼
6. 判断权限码是否在列表中
5. 判断权限码是否在列表中
in_array('*:*:*') → 通配放行
in_array($permissionCode, $permissions)
│ 不包含 → return false → 403
│ 不包含 → HTTP 403 {"code":403,"msg":"无访问权限:sys:xxx:xxx"}
▼
7. 放行
6. 放行
```
### 校验代码
### 校验代码(来自项目实际源码)
```php
// app/middleware/AuthMiddleware.php
// app/Http/Middleware/Authenticate.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;
// 从 Redis 获取权限(一级缓存)
try {
$cached = Redis::get("perms:{$uid}");
if ($cached) {
$perms = json_decode($cached, true) ?? [];
// 拥有通配权限或包含目标编码即通过
return in_array('*:*:*', $perms) || in_array($permissionCode, $perms);
}
} catch (\Exception $e) {
// Redis 不可用时继续降级
}
// 获取角色关联的权限节点
$permissions = RoleMenu::alias('rm')
->join('think_menu m', 'rm.menu_id = m.id')
->whereIn('rm.role_id', $roleIds)
->where('m.is_delete', 0)
->where('m.type', 1) // type=1 为权限节点
->column('m.permission');
// 二级缓存:Laravel Cache(5 分钟)
$cacheKey = "perms_fallback:{$uid}";
$permissions = Cache::remember($cacheKey, 300, function () use ($uid) {
return $this->loadPermissionsFromDb($uid);
});
return in_array($permissionCode, $permissions);
return in_array('*:*:*', $permissions) || in_array($permissionCode, $permissions);
}
// 数据库加载:user_role → role_menu join menu(未删除、type=1、permission 非空)
protected function loadPermissionsFromDb(int $uid): array
{
$roleIds = UserRole::where('user_id', $uid)->pluck('role_id')->toArray();
if (empty($roleIds)) {
return [];
}
return RoleMenu::join('menu', 'role_menu.menu_id', '=', 'menu.id')
->whereIn('role_menu.role_id', $roleIds)
->where('menu.is_delete', 0)
->where('menu.type', 1)
->where('menu.permission', '!=', '')
->pluck('menu.permission')
->toArray();
}
```
@@ -203,126 +229,82 @@ protected function checkPermission(int $uid, string $permissionCode): bool
| 场景 | HTTP 状态 | code | msg |
|------|----------|------|-----|
| 无权限 | 200 | 403 | 无访问权限 |
| 无角色 | 200 | 403 | 无访问权限 |
| 无权限 | **403** | 403 | 无访问权限:{权限码} |
| 无角色(权限列表为空) | **403** | 403 | 无访问权限:{权限码} |
| 缓存过期后重新加载失败 | **403** | 403 | 同上(查库兜底保证一致性) |
:::tip 权限缓存一致性
菜单(`MenuObserver`)或角色菜单(`RoleMenuObserver`)数据变更时自动清除相关缓存;角色管理保存权限后立即生效,无需等待 TTL。
:::
## 在 Controller 中使用
### 声明权限注解
```php
use app\attribute\Log;
use app\attribute\Permission;
use App\Attributes\OperationLog;
use App\Attributes\Permission;
class UserController extends BaseController
class UserController extends Controller
{
// ── 查询类 ──
#[Permission('sys:user:page', '用户分页')]
public function page(): Json { ... }
public function page(): JsonResponse { /* ... */ }
#[Permission('sys:user:detail', '用户详情')]
public function detail(int $id): Json { ... }
public function detail(int $id): JsonResponse { /* ... */ }
// ── 写操作类 ──
#[Log('用户管理-新增记录', Log::TYPE_ADD, '新增用户:{username}')]
#[OperationLog('用户管理-新增', OperationLog::TYPE_ADD, '新增用户:{username}')]
#[Permission('sys:user:add', '添加用户')]
public function add(): Json { ... }
public function add(UserRequest $request): JsonResponse { /* ... */ }
#[Log('用户管理-修改记录', Log::TYPE_UPDATE, '修改用户ID:{id}')]
#[OperationLog('用户管理-修改', OperationLog::TYPE_UPDATE, '修改用户ID:{id}')]
#[Permission('sys:user:update', '修改用户')]
public function update(): Json { ... }
public function update(UserRequest $request): JsonResponse { /* ... */ }
#[Log('用户管理-删除记录', Log::TYPE_DELETE, '删除用户ID:{id}')]
#[OperationLog('用户管理-删除', OperationLog::TYPE_DELETE, '删除用户ID:{id}')]
#[Permission('sys:user:delete', '删除用户')]
public function delete(int $id): Json { ... }
public function delete(int $id): JsonResponse { /* ... */ }
// ── 特殊操作类 ──
#[Log('用户管理-重置密码', Log::TYPE_RESET, '重置用户ID:{id}的密码')]
#[OperationLog('用户管理-重置密码', OperationLog::TYPE_RESET, '重置用户ID:{id}的密码')]
#[Permission('sys:user:resetPwd', '重置密码')]
public function resetPwd(): Json { ... }
public function resetPwd(): JsonResponse { /* ... */ }
// ── 不需要权限的接口 ──
// 不标注 #[Permission] 即可,所有已登录用户都可访问
public function getOptions(): Json { ... }
public function getOptions(): JsonResponse { /* ... */ }
}
```
### 注解组合规则
```text
#[Log] + #[Permission] 组合使用:
- Log:记录操作日志(无论权限校验是否通过)
- Permission:校验权限(不通过返回 403)
#[OperationLog] + #[Permission] 组合使用:
- OperationLog:LogOperation 中间件记录操作日志
- Permission:Authenticate 中间件校验权限(不通过返回 403)
执行顺序:
AuthMiddleware 先校验权限 → 通过后 LogMiddleware 记录日志
Authenticate 先校验权限 → 通过后 LogOperation 才采集日志上下文
权限校验失败时:
- 返回 403,不进入 Controller
- LogMiddleware 可选择是否记录(取决于配置)
- 该次请求不会产生操作日志(尚未进入采集阶段后的业务执行)
```
## 数据权限
### 三种权限范围
三种数据权限范围由 `HasDataScope::getRoleDataScope()` 按角色解析(详见 [5.9 数据权限](/zh/core/data-scope.html))。当前 `laravel_role` 表不含 `data_scope` 字段,读取缺省为 1——角色一律按全部数据处理:
```text
┌─────────────────────────────────────────────────────────┐
│ data_scope = 1 → 全部数据 │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │ A │ │ B │ │ C │ │ D │ │ E │ ← 所有部门所有人的数据 │
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
├─────────────────────────────────────────────────────────┤
│ data_scope = 2 → 本部门数据 │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │ A │ │ B │ │███│ │ D │ │ E │ ← 只看到本部门数据 │
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
├─────────────────────────────────────────────────────────┤
│ data_scope = 3 → 仅本人数据 │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │ A │ │ B │ │███│ │ D │ │ E │ ← 只看到自己创建的数据 │
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
│ data_scope = 1 → 全部数据(不过滤,当前默认) │
│ data_scope = 2 → 本部门数据(WHERE dept_id = 本部门) │
│ data_scope = 3 → 仅本人数据(WHERE create_user = 自己)│
└─────────────────────────────────────────────────────────┘
```
### 配置方式
在 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)处理。
:::
## 权限管理接口
| 接口 | 方法 | 说明 |
@@ -331,19 +313,20 @@ class ArticleLogic extends BaseTenantLogic
| `/role/add` | POST | 添加角色(含菜单权限) |
| `/role/update` | PUT | 修改角色(含菜单权限) |
| `/role/delete/{id}` | DELETE | 删除角色 |
| `/role/menus/{id}` | GET | 获取角色的菜单 ID 列表 |
| `/role/menu/list/{id}` | GET | 获取角色菜单权限列表(含勾选状态) |
| `/role/menu/save` | POST | 保存角色菜单权限 |
| `/menu/treeList` | GET | 菜单树形列表(用于权限分配) |
### 角色权限分配流程
```text
1. 前端调用 GET /menu/treeList 获取完整菜单树
2. 前端调用 GET /role/menus/{roleId} 获取该角色已有的菜单 ID
2. 前端调用 GET /role/menu/list/{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 关联表
5. 前端调用 POST /role/menu/save 提交
{ roleId, menuIds: [1,2,3,...] }
6. 后端写入 role_menu 关联表,RoleMenuObserver 清除该角色权限缓存
```
## 前端权限控制
@@ -364,7 +347,7 @@ class ArticleLogic extends BaseTenantLogic
### 超级管理员
用户权限列表包含 `*:*:*` 时,表示超级管理员,`v-perm`/`v-perms` 指令始终放行。
用户权限列表包含 `*:*:*` 时,表示超级管理员,`v-perm`/`v-perms` 指令始终放行(后端对应 `uid=1` 直接放行)。
### 菜单动态渲染
@@ -382,6 +365,10 @@ class ArticleLogic extends BaseTenantLogic
用户只能看到有权限的菜单
```
:::warning 前端权限不是安全防线
`v-perm` 只是体验层(隐藏按钮);后端 `#[Permission]` 注解 + `auth.jwt` 中间件才是最终防线。
:::
## 最佳实践
| 实践 | 说明 |
+97 -96
View File
@@ -1,7 +1,7 @@
# 5.8 多租户隔离
:::tip 概述
采用共享数据库、共享表、`tenant_id` 字段隔离的方案。通过 `BaseTenantLogic` 基类实现声明式租户隔离,`TenantMiddleware` 注入租户上下文,`BaseLogic::processTenantId()` 自动填充租户ID。
采用共享数据库、共享表、`tenant_id` 字段隔离的方案。通过 `BaseTenantService` 基类实现声明式租户隔离(`use HasDataScope` + `tenantScopeField='tenant_id'`),`ResolveTenant` 中间件注入租户上下文,`processTenantId()` 自动填充租户 ID。
:::
## 设计原则
@@ -10,42 +10,43 @@
┌──────────────────────────────────────────────────────────────┐
│ tenant_id 语义 │
│ │
│ tenant_id = 0 → 超级管理员(平台级,不归属任何租户) │
│ tenant_id = 1 → 默认租户(平台维护数据的归属租户) │
│ tenant_id = 0 → 平台用户(不属于任何租户,查询不过滤) │
│ tenant_id = 1 → 默认租户(DEFAULT_TENANT_ID,平台数据归属)│
│ tenant_id >= 2 → 业务租户(按注册顺序自增) │
│ │
│ ⚠️ 超管创建租户隔离数据时,自动代入默认租户(tenant_id=1) │
│ ⚠️ uid=1(超级管理员)是权限标记,与 tenantId 独立: │
│ 超管创建的租户隔离数据自动代入默认租户(tenant_id=1), │
│ 而非写入 tenant_id=0 │
└──────────────────────────────────────────────────────────────┘
```
:::warning 核心约定
`tenant_id = 0` 是**权限标记**(标识超级管理员),不是**数据归属标记**。超管创建的租户隔离数据归属默认租户(`tenant_id = 1`),而非归属"超管"(`tenant_id = 0`)。
:::warning 两个独立概念
本项目严格区分:**`uid=1` 是权限标记**(跳过权限与租户校验),**`tenantId=0` 是"平台用户"标记**(查询不按租户过滤)。`ResolveTenant` 中间件的 `isSuperAdmin` 按 `uid===1` 判定,与 `tenantId` 是否为 0 无关。超管创建的租户隔离数据归属默认租户(`tenant_id=1`),而非归属"超管"(`tenant_id=0`)。
:::
## 表分类
系统中的表分为两类,通过 Logic 基类选择来决定:
系统中的表分为两类,通过 Service 基类选择来决定:
| 类型 | 基类 | 说明 | 举例 |
|------|------|------|------|
| 系统共享表 | `BaseLogic` | 不做租户隔离,所有角色均可访问 | Menu、Dict、Config、Param、Tenant、FileTemplate |
| 租户隔离表 | `BaseTenantLogic` | 按 `tenant_id` 自动过滤和填充 | User、Role、Article、Category、Dept、Level、Position、Link、Notice |
| 系统共享表 | `BaseService` | 不做租户隔离,所有角色均可访问 | Menu、Dict、Config、Param、Tenant、FileTemplate、Example |
| 租户隔离表 | `BaseTenantService` | 按 `tenant_id` 自动过滤和填充 | User、Role、Article、Category、Dept、Level、Position、Link、Notice |
```php
// 系统共享表:继承 BaseLogic
class MenuLogic extends BaseLogic { ... }
class DictLogic extends BaseLogic { ... }
// 系统共享表:继承 BaseService
class MenuService extends BaseService { /* ... */ }
class DictCacheService extends BaseService { /* ... */ }
// 租户隔离表:继承 BaseTenantLogic
class UserLogic extends BaseTenantLogic { ... }
class RoleLogic extends BaseTenantLogic { ... }
class ArticleLogic extends BaseTenantLogic { ... }
// 租户隔离表:继承 BaseTenantService
class UserService extends BaseTenantService { /* ... */ }
class RoleService extends BaseTenantService { /* ... */ }
class ArticleService extends BaseTenantService { /* ... */ }
```
:::danger 继承选错的后果
- 租户隔离表继承了 `BaseLogic` → 数据泄漏,所有租户互相可见
- 系统共享表继承了 `BaseTenantLogic` → 菜单/字典等数据被隔离,租户用户看不到
- 租户隔离表继承了 `BaseService` → 数据泄漏,所有租户互相可见
- 系统共享表继承了 `BaseTenantService` → 菜单/字典等数据被隔离,租户用户看不到
:::
## 隔离机制总览
@@ -54,87 +55,102 @@ class ArticleLogic extends BaseTenantLogic { ... }
请求进入
│
▼
┌──────────────────────┐
│ AuthMiddleware │ 解析 JWT → 注入 request->userInfo
└──────────┬───────────┘
┌────────────────────────────┐
│ Authenticate │ 解析 JWT → 注入 attributes['userInfo']
└──────────┬─────────────────┘
│
▼
┌──────────────────────┐
│ TenantMiddleware │ 查询用户 tenant_id → 注入 request->tenantId
│ │ 校验租户状态(存在、启用、未过期)
└──────────┬───────────┘
┌────────────────────────────┐
│ ResolveTenant │ 查库取 tenant_id → 注入 attributes['tenantId']
│ │ 校验租户状态(存在、启用、未过期)
└──────────┬─────────────────┘
│
▼
┌──────────────────────┐
│ BaseLogic │
│ ├─ applyTenantScope │ 查询时:tenantId=0 不过滤,tenantId>0 加 WHERE
│ └─ processTenantId │ 新增时:tenantId=0 代入默认租户,tenantId>0 自动填充
└──────────────────────┘
┌────────────────────────────┐
│ BaseTenantService │
│ (use HasDataScope) │
│ ├─ applyTenantScope │ 查询时:平台用户(0)不过滤,租户用户加 WHERE
│ └─ processTenantId │ 新增时:平台用户代入默认租户,租户用户自动填充
└────────────────────────────┘
```
## 租户中间件
## 租户中间件(ResolveTenant,来自项目实际源码)
```php
// app/middleware/TenantMiddleware.php
// app/Http/Middleware/ResolveTenant.php(节选)
public function handle(Request $request, \Closure $next): Response
public function handle(Request $request, Closure $next): Response
{
// 从 JWT 注入的 userInfo 取用户 ID
$userInfo = $request->userInfo ?? null;
$userId = $userInfo->uid ?? 0;
$userInfo = $request->attributes->get('userInfo');
if (! $userInfo) {
// 未认证请求:不注入租户上下文,直接放行
return $next($request);
}
$uid = $userInfo->uid ?? 0;
// 超级管理员(uid=1):跳过租户校验,直接注入 tenantId=0
if ($uid === 1) {
$request->attributes->set('tenantId', 0);
$request->attributes->set('isSuperAdmin', true);
return $next($request);
}
// 非超管:查库取 tenant_id(JWT 不含 tenant_id,需查库)
$user = User::find($uid);
if (! $user) {
return response()->json([
'code' => 401, 'ok' => false,
'msg' => '用户不存在,请重新登录', 'data' => null,
]);
}
$user = User::find($userId);
$tenantId = $user->tenant_id ?? 0;
$request->attributes->set('tenantId', $tenantId);
$request->attributes->set('isSuperAdmin', false);
// 注入租户上下文
$request->tenantId = $tenantId;
$request->isSuperAdmin = ($tenantId == 0);
// 非超级管理员校验租户状态
// 平台用户(tenantId=0):跳过租户校验
if ($tenantId > 0) {
$tenant = Tenant::find($tenantId);
if (!$tenant) {
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;
// 租户不存在 → 403「租户不存在」
// 租户被禁用(status != 1)→ 403「租户已被禁用」
// 租户已过期(expire_time->isPast())→ 403「租户已过期」
$request->attributes->set('tenantInfo', $tenant);
}
return $next($request);
}
```
| 注入字段 | 类型 | 说明 |
| 注入字段(`request->attributes`) | 类型 | 说明 |
|----------|------|------|
| `request->tenantId` | `int` | 当前用户所属租户ID,超管为 0 |
| `request->isSuperAdmin` | `bool` | 是否为超级管理员(tenantId == 0) |
| `request->tenantInfo` | `Tenant\|null` | 租户模型实例,超管为 null |
| `tenantId` | `int` | 当前用户所属租户 ID,0=平台用户 |
| `isSuperAdmin` | `bool` | 是否超级管理员(**uid === 1** 时为 true) |
| `tenantInfo` | `Tenant\|null` | 租户模型实例,平台用户为 null |
## 查询隔离(applyTenantScope)
```php
// app/BaseLogic.php
// app/Services/Concerns/HasDataScope.php
protected function applyTenantScope($model)
protected function applyTenantScope($query)
{
if (empty($this->tenantScopeField)) {
return $model;
return $query;
}
$tenantId = request()->tenantId ?? 0;
$tenantId = request()->attributes->get('tenantId', 0);
// 超级管理员不过滤(看到所有租户数据)
// 平台用户不过滤(可查看所有租户数据)
if ($tenantId == 0) {
return $model;
return $query;
}
// 租户用户:只看到自己租户的数据
return $model->where($this->tenantScopeField, $tenantId);
return $query->where($this->tenantScopeField, $tenantId);
}
```
@@ -142,38 +158,22 @@ protected function applyTenantScope($model)
| 角色 | tenantId | 查询条件 | 可见数据 |
|------|----------|----------|----------|
| 超级管理员 | 0 | 无 tenant_id 过滤 | 所有租户数据 |
| 超级管理员(uid=1) | 0 | 无 tenant_id 过滤 | 所有租户数据 |
| 平台用户 | 0 | 无 tenant_id 过滤 | 所有租户数据 |
| 默认租户用户 | 1 | `WHERE tenant_id = 1` | 默认租户数据(含超管创建的数据) |
| 业务租户用户 | 2 | `WHERE tenant_id = 2` | 仅本租户数据 |
## 新增填充(processTenantId)
```php
// app/BaseLogic.php
// app/Services/Concerns/HasDataScope.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;
// 未配置 tenantScopeField 则跳过
// 平台用户(tenantId=0)→ 代入 config('tenant.default_tenant_id')(默认 1)
// 租户用户(tenantId>0)→ 自动填充当前 tenantId
// 字段已有显式值时不覆盖
}
```
@@ -181,14 +181,15 @@ protected function processTenantId(array $data): array
| 角色 | tenantId | 写入的 tenant_id | 说明 |
|------|----------|-----------------|------|
| 超级管理员 | 0 | 1(默认租户) | 自动代入,可通过配置修改 |
| 超级管理员 / 平台用户 | 0 | 1(默认租户) | 自动代入,`DEFAULT_TENANT_ID` 可配置 |
| 默认租户用户 | 1 | 1 | 自动填充 |
| 业务租户用户 | 2 | 2 | 自动填充 |
:::tip 为什么不写入 tenant_id = 0?
如果超管创建的数据写入 `tenant_id = 0`:
如果平台侧创建的数据写入 `tenant_id = 0`:
- 租户用户查询条件是 `WHERE tenant_id = N`,永远看不到这些数据
- 语义混乱:`tenant_id = 0` 是权限标记,不应作为数据归属
- 语义混乱:`tenant_id = 0` 是"平台用户"标记,不应作为数据归属
- 数据迁移困难:无法区分"平台数据"和"脏数据"
:::
@@ -196,10 +197,9 @@ protected function processTenantId(array $data): array
```php
// config/tenant.php
return [
// 默认租户ID,超管操作租户隔离表时自动代入
'default_tenant_id' => 1,
// 默认租户ID,平台用户操作租户隔离表时自动代入(.env: DEFAULT_TENANT_ID)
'default_tenant_id' => env('DEFAULT_TENANT_ID', 1),
];
```
@@ -207,11 +207,11 @@ return [
| 操作 | 行为 |
|------|------|
| 查询租户隔离数据 | 不过滤,看到所有租户数据 |
| 查询租户隔离数据 | 不过滤(uid=1 注入 tenantId=0),看到所有租户数据 |
| 新增租户隔离数据 | 自动填充 `tenant_id = 1`(默认租户) |
| 查询系统共享数据 | 正常访问(共享表本身无租户过滤) |
| 权限校验 | 跳过(uid = 1 直接放行) |
| 租户状态校验 | 跳过(tenantId = 0 不校验) |
| 租户状态校验 | 跳过(tenantId = 0 不进入校验分支) |
## 与数据权限的关系
@@ -233,13 +233,14 @@ return [
└─────────────────────────────────────────────┘
```
租户隔离优先级更高。详见 [数据权限](/zh/core/data-scope.html)。
租户隔离优先级更高。详见 [5.9 数据权限](/zh/core/data-scope.html)。
## 开发 Checklist
新增业务模块时,按以下清单判断租户隔离:
- [ ] 该模块数据是否需要按租户隔离?→ 是:继承 `BaseTenantLogic`,否:继承 `BaseLogic`
- [ ] 该模块数据是否需要按租户隔离?→ 是:继承 `BaseTenantService`,否:继承 `BaseService`
- [ ] 数据库表是否需要 `tenant_id` 字段?→ 租户隔离表必须有
- [ ] 是否需要数据权限(部门/创建人过滤)?→ 配置 `dataScopeDeptField` 或 `dataScopeUserField`
- [ ] 数据库脚本中是否已正确设置 `tenant_id`?→ 提供的数据库脚本已包含完整结构和数据
- [ ] 租户角色/用户是否已创建?→ 租户管理中创建并分配账号