签入版本

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