# 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` |