# 5.14 操作日志 :::tip 概述 操作日志通过 `#[OperationLog]` 注解 + `LogOperation` 中间件自动记录,无需手动埋点。**`handle` 阶段只采集不落库,`terminate` 阶段在响应完成后写入**(不阻塞业务);所有写操作(POST/PUT/DELETE/PATCH)始终记录,GET 请求仅当注解类型为导出/下载/导入时记录。日志写入失败静默处理,不影响主业务流程。 ::: ## 记录流程 ```text 请求进入 │ ▼ LogOperation::handle() │ ├─ 反射读取 #[OperationLog] 注解(ReflectionMethod,微秒级) │ ├─ 判断是否需要记录(determineIfShouldLog) │ ├─ POST/PUT/DELETE/PATCH → 始终记录 ✓ │ ├─ GET + 注解类型为 TYPE_EXPORT(6)/TYPE_DOWNLOAD(16)/TYPE_IMPORT(5) → 记录 ✓ │ └─ 其它 GET / OPTIONS / HEAD → 不记录 ✗ │ ├─ 无需记录 → 直接放行 │ ├─ 收集基础上下文(method、url、param、ip) │ param = JSON(排除 password/token,截取前 2000 字符) │ ├─ 注入 title / type(注解优先;无注解时 title 回退请求路径、 │ type 按 HTTP 方法推断:POST→新增 PUT→修改 DELETE→删除) │ ├─ 预解析 RequestInfoService(IP 归属地/OS/浏览器,写入上下文) │ (handle 阶段 request 信息完整,terminate 阶段可能已丢失) │ ├─ 上下文挂载到 request attributes(键 operation_log.context) │ (handle 与 terminate 由框架以不同实例执行,实例状态无法跨阶段传递) │ └─ $next($request) 继续执行业务 │ ▼ 响应发送完成后 LogOperation::terminate() │ ├─ 从 request attributes 读取上下文,不存在(无需记录)→ 直接返回 │ ├─ 补充响应内容 result(截取前 2000 字符) ├─ 补充 consume_time(基于 LARAVEL_START 计算,毫秒) ├─ 补充 status(检测响应 code:0/200=正常 0,其余=异常 1) ├─ 补充 error(异常时取响应中的 msg) ├─ 补充 create_user(来自 Authenticate 注入的 userInfo.username) │ └─ OperationLog::create(...) (try-catch 包裹,失败静默不影响业务) ``` ## 记录字段 | 字段 | 说明 | 来源 | 示例 | |------|------|------|------| | `source` | 来源端 | 固定 0(0=后台 1=前台) | 0 | | `title` | 操作标题 | `#[OperationLog]` 注解 title 参数,无注解回退请求路径 | 用户管理-新增 | | `type` | 操作类型 | 注解 type 参数(默认按 HTTP 方法推断) | 1=新增 | | `method` | HTTP 方法 | 请求方法 | POST | | `request_method` | 控制器方法名 | 路由 action | add | | `url` | 请求路径(不含域名) | `fullUrl` 截取路径部分 | /api/user/add | | `param` | 请求参数 | JSON **排除 password/token**,截取前 2000 字符 | {"username":"admin"} | | `result` | 响应内容 | JSON 截取前 2000 字符 | {"code":0,"msg":"添加成功"} | | `ip` | 客户端 IP | RequestInfoService(经 ProxyMiddleware 还原) | 192.168.1.100 | | `location` | IP 归属地 | zoujingli/ip2region | 江苏省南京市 | | `os` | 操作系统 | RequestInfoService | Windows 11 | | `browser` | 浏览器 | RequestInfoService | Chrome 128 | | `consume_time` | 执行耗时 | 毫秒(`LARAVEL_START` 起算) | 125 | | `status` | 状态 | 响应 code 判断(0/200=0,其余=1) | 0=正常 1=异常 | | `error` | 错误信息 | 响应中的 msg(异常时) | 名称已存在 | | `create_user` | 操作人 | 当前登录用户名 | admin | | `create_time` | 操作时间 | terminate 阶段写入 | 2026-10-02 10:30:00 | ## 注解使用 ### 基本用法 ```php // 写操作(POST/PUT/DELETE/PATCH)始终记录 #[OperationLog('用户管理-新增', OperationLog::TYPE_ADD)] public function add(): JsonResponse { ... } #[OperationLog('用户管理-修改', OperationLog::TYPE_UPDATE)] public function update(): JsonResponse { ... } #[OperationLog('用户管理-删除', OperationLog::TYPE_DELETE)] public function delete(int $id): JsonResponse { ... } ``` ### 带描述占位符 ```php #[OperationLog('用户管理-删除', OperationLog::TYPE_DELETE, '删除用户ID:{id}')] public function delete(int $id): JsonResponse { ... } // 占位符 {id} 会被替换为请求参数中 id 的值 // 实际记录:"删除用户ID:42" #[OperationLog('用户管理-新增', OperationLog::TYPE_ADD, '新增用户:{username}')] public function add(UserRequest $request): JsonResponse { ... } // 实际记录:"新增用户:admin" #[OperationLog('用户管理-批量删除', OperationLog::TYPE_DELETE)] public function batchDelete(): JsonResponse { ... } ``` ### 导出操作(GET 也会记录) ```php #[OperationLog('用户管理-导出数据', OperationLog::TYPE_EXPORT)] public function export(): JsonResponse { ... } #[OperationLog('用户管理-导入数据', OperationLog::TYPE_IMPORT, '通过Excel导入用户数据')] public function import(): JsonResponse { ... } ``` :::warning GET 请求的记录策略 GET 请求**仅当**注解类型为 `TYPE_IMPORT(5)`、`TYPE_EXPORT(6)`、`TYPE_DOWNLOAD(16)` 时才会记录日志。普通查询(`TYPE_QUERY`)不会被记录,以避免大量查询淹没日志——这也是 `LogOperation::determineIfShouldLog()` 的硬性规则,改注解类型也绕不开。 ::: ### 与 Permission 注解组合 ```php #[OperationLog('用户管理-新增', OperationLog::TYPE_ADD, '新增用户:{username}')] #[Permission('sys:user:add', '添加用户')] public function add(UserRequest $request): JsonResponse { ... } // 执行顺序: // 1. Authenticate 校验权限(#[Permission]),失败返回 403(该次请求不产生日志) // 2. 权限通过后进入 Controller // 3. LogOperation terminate 阶段记录日志(#[OperationLog]) ``` ## 操作类型常量 `app/Attributes/OperationLog.php` 共定义 **30 种**操作类型常量: | 常量 | 值 | 说明 | GET 是否记录 | |------|-----|------|-------------| | `TYPE_ADD` | 1 | 新增 | — | | `TYPE_UPDATE` | 2 | 修改 | — | | `TYPE_DELETE` | 3 | 删除 | — | | `TYPE_QUERY` | 4 | 查询 | ✗ | | `TYPE_IMPORT` | 5 | 导入 | ✓ | | `TYPE_EXPORT` | 6 | 导出 | ✓ | | `TYPE_APPROVE` | 7 | 审批 | — | | `TYPE_REJECT` | 8 | 驳回 | — | | `TYPE_SUBMIT` | 9 | 提交 | — | | `TYPE_WITHDRAW` | 10 | 撤回 | — | | `TYPE_CLOSE` | 11 | 关闭 | — | | `TYPE_OPEN` | 12 | 开启 | — | | `TYPE_ENABLE` | 13 | 启用 | — | | `TYPE_DISABLE` | 14 | 禁用 | — | | `TYPE_UPLOAD` | 15 | 上传 | — | | `TYPE_DOWNLOAD` | 16 | 下载 | ✓ | | `TYPE_SEND` | 17 | 发送 | — | | `TYPE_SYNC` | 18 | 同步 | — | | `TYPE_GENERATE` | 19 | 生成 | — | | `TYPE_CLEAR` | 20 | 清理(如清缓存) | — | | `TYPE_RESTORE` | 21 | 恢复 | — | | `TYPE_LOGIN` | 22 | 登录 | — | | `TYPE_LOGOUT` | 23 | 登出 | — | | `TYPE_RESET` | 24 | 重置 | — | | `TYPE_ASSIGN` | 25 | 分配 | — | | `TYPE_AUTHORIZE` | 26 | 授权 | — | | `TYPE_EXECUTE` | 27 | 执行 | — | | `TYPE_PAUSE` | 28 | 暂停 | — | | `TYPE_RESUME` | 29 | 恢复执行 | — | | `TYPE_OTHER` | 99 | 其他 | — | (`type` 未指定时默认 0,由中间件按 HTTP 方法推断。) ## API 接口 | 方法 | 路径 | 权限码 | 说明 | |------|------|--------|------| | GET | `/api/oper/log/page` | sys:operLog:page | 分页列表 | | GET | `/api/oper/log/detail/{id}` | sys:operLog:detail | 详情 | | DELETE | `/api/oper/log/delete/{id}` | sys:operLog:delete | 删除 | | DELETE | `/api/oper/log/batchDelete` | sys:operLog:batchDelete | 批量删除 | ## 核心代码 ```php // app/Http/Middleware/LogOperation.php(节选) public function handle(Request $request, Closure $next): Response { $logAttr = $this->getLogAttribute($request); // 反射读取注解 if (! $this->determineIfShouldLog($request->method(), $logAttr)) { return $next($request); } $context = [ 'source' => 0, 'method' => strtoupper($request->method()), 'request_method' => $request->route()?->getActionMethod() ?? '', 'url' => parse_url($request->fullUrl(), PHP_URL_PATH) ?: '/', 'param' => mb_substr(json_encode($request->except(['password', 'token']), JSON_UNESCAPED_UNICODE), 0, 2000), 'ip' => $request->ip(), 'title' => $logAttr?->title ?: $request->path(), // 无注解回退请求路径 'type' => $logAttr?->type ?: $this->guessType($request->method()), // 无注解按方法推断 ]; // 请求信息在响应发出前最完整,handle 阶段预解析并写入上下文 $requestInfo = RequestInfoService::create($request); $context['location'] = $requestInfo->getIpLocation(); $context['os'] = $requestInfo->getOs(); $context['browser'] = $requestInfo->getBrowser(); // 上下文挂载到请求属性(handle 与 terminate 由框架以不同实例执行, // 实例状态无法跨阶段传递,必须挂在请求上) $request->attributes->set(self::CONTEXT_KEY, $context); return $next($request); } public function terminate(Request $request, Response $response): void { $context = $request->attributes->get(self::CONTEXT_KEY); if (! is_array($context)) { return; } // 取出即删除,防止同一次请求重复写入 $request->attributes->remove(self::CONTEXT_KEY); try { $content = $response->getContent(); $statusCode = $this->detectStatus($content); $context['result'] = mb_substr($content ?? '', 0, 2000); $context['consume_time'] = defined('LARAVEL_START') ? round((microtime(true) - LARAVEL_START) * 1000) : 0; $context['status'] = $statusCode; $context['error'] = $statusCode ? ($this->detectError($content) ?? '') : ''; $userInfo = $request->attributes->get('userInfo'); $context['create_user'] = $userInfo->username ?? ''; OperationLog::create($context); } catch (\Exception $e) { // 日志写入失败不影响业务 } } ``` :::tip 为什么 handle/terminate 分离? 日志采集需要请求阶段的信息(IP、UA、参数),而写入放在响应之后——既拿到了完整上下文,又不占用业务响应时间。这是 Laravel 中间件 `terminable` 的标准用法。 ::: ## 日志查询与分析 ### 按操作人查询 ```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-10-02 ``` ### 按状态查询(查异常操作) ```text GET /api/oper/log/page?status=1 (查询所有失败的操作) ``` ## 日志保留策略 | 策略 | 说明 | 适用场景 | |------|------|---------| | 按时间清理 | 定期删除 N 天前的日志 | 大多数场景 | | 按数量保留 | 只保留最近 N 条 | 日志量大 | | 归档到冷存储 | 导出到文件/对象存储 | 合规要求 | ```sql -- 示例:清理 90 天前的操作日志 DELETE FROM laravel_operation_log WHERE create_time < DATE_SUB(NOW(), INTERVAL 90 DAY); -- 示例:按月归档 SELECT * FROM laravel_operation_log WHERE create_time >= '2026-09-01' AND create_time < '2026-10-01' INTO OUTFILE '/backup/operation_log_202609.csv'; ``` ## 与登录日志的区别 | 维度 | 操作日志 | 登录日志 | |------|---------|---------| | 表 | `laravel_operation_log` | `laravel_login_log` | | 记录方 | `LogOperation` 中间件(terminate 落库) | `AuthController` 手动记录 | | 记录内容 | 写操作 + 导出/导入/下载 | 仅登录/登出(成功失败均记) | | 触发方式 | `#[OperationLog]` 注解 | 代码中 `writeLoginLog()` | | 用途 | 业务操作审计 | 登录安全审计 | ## 最佳实践 | 实践 | 说明 | |------|------| | 标题语义化 | 使用"模块-操作"格式,如"用户管理-新增" | | 描述加关键参数 | 用 `{param}` 占位符记录业务主键,便于追溯 | | 敏感操作必须标注 | 删除、重置密码、权限变更等操作必须加 `#[OperationLog]` | | 查询不依赖日志 | 普通查询 `TYPE_QUERY` 不会落库,勿以此做查询审计 | | 定期清理 | 避免日志表无限增长影响性能 |