# 5.7 演示模式
:::tip 概述
演示模式是系统内置的安全机制,用于公共演示环境的数据保护。开启后,`DemoMiddleware` 自动拦截所有写操作(POST/PUT/DELETE/PATCH),仅允许读操作(GET)通过,防止演示环境被随意修改。通过 `#[DemoAllow]` 注解可豁免特定接口。
:::
## 应用场景
```text
┌─────────────────────────────────────────────────────────────────┐
│ 演示模式典型场景 │
│ │
│ ✅ 在线体验站:开放给潜在客户试用,但不允许修改数据 │
│ ✅ 培训环境:学员可以浏览所有功能,但不能增删改 │
│ ✅ 公开演示:展会/发布会现场演示,防止误操作 │
│ ✅ 安全评审:安全团队审查时只允许只读访问 │
│ │
│ ⚠️ 部分"安全的写操作"仍需放行(如刷新缓存、代码预览) │
│ → 通过 #[DemoAllow] 注解豁免 │
└─────────────────────────────────────────────────────────────────┘
```
## 配置
```ini
# .env
# 开启演示模式(true=开启,false=关闭)
DEMO_MODE=true
```
| 变量 | 默认值 | 读取位置 | 说明 |
|------|--------|---------|------|
| `DEMO_MODE` | `false` | `config('app.demo_mode')` → DemoMiddleware | 演示模式开关,`true` 开启拦截,`false` 正常运行 |
:::warning 生产环境务必关闭
演示模式会拦截所有写操作,生产环境必须保持 `DEMO_MODE=false`,否则用户无法执行任何增删改操作。
:::
## 启用与验证
演示模式生效需**同时满足两个条件**,缺一不可:
1. **配置接入**:`config/app.php` 定义 `demo_mode` 键(读取 `DEMO_MODE` 环境变量);
2. **中间件挂载**:`demo` 中间件已挂载到目标路由组(见[中间件挂载与执行顺序](#中间件挂载与执行顺序))。
启用步骤:
```bash
# ① .env 开启
DEMO_MODE=true
# ② 清除配置缓存(如执行过 config:cache,改完需重新缓存)
php artisan config:clear
```
逐项验证:
```bash
# ① 配置键已接入(期望输出 true)
php artisan tinker --execute="dump(config('app.demo_mode'))"
# ② 中间件已挂载到认证路由组(demo 位于 tenant 与 log.operation 之间)
php artisan route:list --path=user/add --json
# → "middleware": ["api", "auth.jwt", "tenant", "demo", "log.operation"]
# ③ 拦截生效(携带有效 token 发起写操作,期望 HTTP 403)
curl -X POST http://localhost:8000/api/user/add \
-H "Authorization: Bearer {access_token}" \
-H "Accept: application/json" \
-d '{"username":"demo"}'
# → {"code":403,"ok":false,"msg":"演示模式下禁止操作","data":null}
```
:::warning 两层缺一不可
仅设置 `DEMO_MODE=true` 不代表生效:
- 若 `config/app.php` 缺少 `demo_mode` 键,`config('app.demo_mode')` 永远返回默认值 `false`,中间件直接放行;
- 若 `demo` 中间件未挂载到路由组,`DemoMiddleware::handle()` 根本不会执行。
启用后请按上述清单逐项验证,不要只改 `.env`。
:::
## 拦截规则
```text
请求进入
│
▼
┌──────────────────────────────┐
│ ① demo_mode = false ? │──── 是 ──→ 直接放行(非演示环境)
└──────────┬───────────────────┘
│ 否
▼
┌──────────────────────────────┐
│ ② 请求方法 = GET ? │──── 是 ──→ 直接放行(读操作)
└──────────┬───────────────────┘
│ 否(POST/PUT/DELETE/PATCH)
▼
┌──────────────────────────────┐
│ ③ 标注 #[DemoAllow] ? │──── 是 ──→ 直接放行(白名单豁免)
│ (ReflectionMethod 反射读取, │
│ 反射异常静默视为未标注) │
└──────────┬───────────────────┘
│ 否
▼
┌──────────────────────────────┐
│ HTTP 403 │
│ {"code":403, │
│ "msg":"演示模式下禁止操作"} │
└──────────────────────────────┘
```
| 请求方法 | 行为 | 说明 |
|---------|------|------|
| GET | ✅ 放行 | 读操作始终允许 |
| POST | ❌ 拦截 | 新增操作 |
| PUT | ❌ 拦截 | 修改操作 |
| DELETE | ❌ 拦截 | 删除操作 |
| PATCH | ❌ 拦截 | 部分修改操作 |
| 任意方法 + `#[DemoAllow]` | ✅ 放行 | 白名单豁免 |
## 中间件挂载与执行顺序
`DemoMiddleware` 以别名 `demo` 注册于 `bootstrap/app.php`,并挂载在认证路由组上(`routes/api.php`):
```php
// bootstrap/app.php —— 别名注册
$middleware->alias([
// ...
'demo' => DemoMiddleware::class,
]);
// routes/api.php —— 认证路由组
// demo 位于 tenant 之后、log.operation 之前
Route::middleware(['auth.jwt', 'tenant', 'demo', 'log.operation'])
->group(function () { /* ... */ });
```
```text
挂载后的执行顺序(认证路由组内):
ProxyMiddleware(全局)
→ Cors(全局)
→ ForceJsonResponse(全局)
→ Authenticate(认证 + 权限)
→ ResolveTenant(租户上下文)
→ DemoMiddleware(演示模式拦截) ← 本中间件
→ LogOperation(操作日志)
→ 控制器
```
:::tip 执行位置说明
DemoMiddleware 设计在 `Authenticate` 和 `ResolveTenant` 之后执行,意味着:
- 未登录用户已被 `Authenticate` 拦截(401),不会到达演示模式判断;
- 租户状态已由 `ResolveTenant` 校验,确保用户合法;
- **位于 `LogOperation` 之前**:被演示模式拦截(403)的请求不再写入操作日志;
- 演示模式仅拦截"合法用户的写操作",不影响认证流程。
登录、验证码、令牌刷新等公开接口定义在路由组外,不经过 `demo` 中间件。
:::
## 核心代码
### DemoMiddleware
```php
// app/Http/Middleware/DemoMiddleware.php(节选)
class DemoMiddleware
{
public function handle(Request $request, Closure $next): Response
{
// ① 非演示模式直接放行
if (! config('app.demo_mode', false)) {
return $next($request);
}
// (后续:GET 放行 → 反射 #[DemoAllow] 放行 → 否则 403)
// 注解读取通过反射完成,反射异常时静默处理,视为未标注;
// 未放行的写操作统一返回 403,响应结构与 Result::fail() 一致。
}
}
```
### DemoAllow 注解
```php
// app/Attributes/DemoAllow.php
#[Attribute(Attribute::TARGET_METHOD)] // 仅支持方法级,不支持类级
class DemoAllow
{
// 无参数的标记注解,仅用于标识"允许在演示环境执行"
}
```
### 注解读取(中间件内反射)
```php
// DemoMiddleware 通过 ReflectionMethod 读取方法上的 #[DemoAllow]
$controllerClass = $route->getControllerClass();
$method = $route->getActionMethod();
$reflection = new \ReflectionMethod($controllerClass, $method);
$attrs = $reflection->getAttributes(DemoAllow::class);
if (! empty($attrs)) {
return $next($request); // 白名单放行
}
```
:::tip 反射性能
注解读取开销为微秒级(与 `Authenticate`、`LogOperation` 一致),不做缓存——避免开发阶段改注解后需等缓存过期。
:::
## 白名单示例
当前代码中标注 `#[DemoAllow]` 的方法:
| 控制器 | 方法 | 路由 | 说明 | 放行理由 |
|--------|------|------|------|---------|
| `GeneratorController` | `preview` | `GET generator/preview` | 代码生成预览 | 仅预览不写盘,不修改业务数据 |
| `AuthController` | `logout` | `POST logout` | 用户登出 | 吊销当前令牌并写登录日志(`login_log`),不修改业务数据;演示环境必须允许退出 |
:::tip GET 路由天然放行
`dict/refreshCache` 等维护接口均为 GET 方法,本就不受拦截、无需注解。`#[DemoAllow]` 的价值在于**防御性兜底**——即使后续将路由方法改为 POST,演示模式下仍可正常访问。
:::
### 使用示例
```php
use App\Attributes\DemoAllow;
use App\Attributes\OperationLog;
use App\Attributes\Permission;
class GeneratorController extends Controller
{
/**
* 预览生成代码
*
* 标注 #[DemoAllow],演示环境下允许访问。
* 根据请求参数预览待生成的代码内容,不写入文件。
*/
#[OperationLog('代码生成-预览代码', OperationLog::TYPE_QUERY)]
#[Permission('sys:generator:generate', '代码生成')]
#[DemoAllow] // ← 演示模式白名单
public function preview(Request $request): JsonResponse
{
$result = $this->logic->preview($request->all());
return $this->success($result);
}
}
```
:::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
删除
```
## 响应示例
当演示模式拦截写操作时,返回(HTTP 状态码 403):
```json
{
"code": 403,
"ok": false,
"msg": "演示模式下禁止操作",
"data": null
}
```
## 常见问题
### Q: 演示模式下登录还能用吗?
能。登录接口 `/api/login` 位于**公开接口组**(中间件组外),不经过 `demo` 中间件,因此不受演示模式影响。
### Q: 设置 DEMO_MODE=true 后仍能正常增删改?
演示模式生效需同时满足**配置接入**与**中间件挂载**两个条件,逐项排查(详见[启用与验证](#启用与验证)):
```bash
php artisan tinker --execute="dump(config('app.demo_mode'))" # 期望 true
php artisan route:list --path=user/add --json # middleware 应含 demo
```
若配置键缺失或中间件未挂载,改 `.env` 不会有任何效果。另外注意:执行过 `config:cache` 时,改完 `.env` 需重新缓存。
### Q: 如何让某个 POST 接口在演示模式下也能用?
在控制器方法上标注 `#[DemoAllow]` 注解即可:
```php
#[DemoAllow]
public function mySafeAction(): JsonResponse
{
// 演示模式下也可执行
}
```
### Q: 演示模式和权限控制会冲突吗?
不会。两者是独立机制,按顺序执行:
1. **Authenticate** 先校验权限(无权限返回 403 "无访问权限:{权限码}")
2. **DemoMiddleware** 再判断演示模式(拦截写操作返回 403 "演示模式下禁止操作")
两者的 403 含义不同,前端可通过 `msg` 字段区分。
### Q: 临时关闭演示模式需要重启服务吗?
修改 `.env` 后需清除配置缓存并重启:
```bash
php artisan config:clear
# 生产环境如执行过 config:cache,改完需重新缓存
php artisan config:cache
```
### Q: `#[DemoAllow]` 支持类级标注吗?
不支持。`DemoAllow` 注解的 `Attribute` 目标仅为 `TARGET_METHOD`(方法级)。如果需要整个控制器放行,需在每个方法上单独标注。