Files
rxthinkcmf-wiki/RXThinkCMF_Laravel13_NaiveVue/zh/core/operation-log.md
T
2026-10-08 17:50:29 +08:00

12 KiB
Raw Blame History

5.14 操作日志

:::tip 概述 操作日志通过 #[OperationLog] 注解 + LogOperation 中间件自动记录,无需手动埋点。handle 阶段只采集不落库,terminate 阶段在响应完成后写入(不阻塞业务);所有写操作(POST/PUT/DELETE/PATCH)始终记录,GET 请求仅当注解类型为导出/下载/导入时记录。日志写入失败静默处理,不影响主业务流程。 :::

记录流程

请求进入
    │
    ▼
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

注解使用

基本用法

// 写操作(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 { ... }

带描述占位符

#[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 也会记录)

#[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 注解组合

#[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 批量删除

核心代码

// 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 的标准用法。 :::

日志查询与分析

按操作人查询

GET /api/oper/log/page?createUser=admin&pageNo=1&pageSize=20

按操作类型查询

GET /api/oper/log/page?type=3&pageNo=1&pageSize=20
(查询所有删除操作)

按时间范围查询

GET /api/oper/log/page?startTime=2026-09-01&endTime=2026-10-02

按状态查询(查异常操作)

GET /api/oper/log/page?status=1
(查询所有失败的操作)

日志保留策略

策略 说明 适用场景
按时间清理 定期删除 N 天前的日志 大多数场景
按数量保留 只保留最近 N 条 日志量大
归档到冷存储 导出到文件/对象存储 合规要求
-- 示例:清理 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 不会落库,勿以此做查询审计
定期清理 避免日志表无限增长影响性能