# 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 ``` ## 响应示例 当演示模式拦截写操作时,返回: ```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`(方法级)。如果需要整个控制器放行,需在每个方法上单独标注。