12 KiB
12 KiB
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 不会落库,勿以此做查询审计 |
| 定期清理 | 避免日志表无限增长影响性能 |