签入版本

This commit is contained in:
2026-10-08 17:50:29 +08:00
parent 809750b47e
commit e213a9b31d
444 changed files with 39366 additions and 37533 deletions
@@ -1,7 +1,7 @@
# 5.14 操作日志
:::tip 概述
操作日志通过 `#[Log]` 注解 + LogMiddleware 自动记录,无需手动埋点。所有写操作(POST/PUT/DELETE/PATCH)始终记录,GET 请求仅当注解类型为导出/下载/导入时记录。日志写入失败不影响主业务流程。
操作日志通过 `#[OperationLog]` 注解 + `LogOperation` 中间件自动记录,无需手动埋点。**`handle` 阶段只采集不落库,`terminate` 阶段在响应完成后写入**(不阻塞业务);所有写操作(POST/PUT/DELETE/PATCH)始终记录,GET 请求仅当注解类型为导出/下载/导入时记录。日志写入失败静默处理,不影响主业务流程。
:::
## 记录流程
@@ -10,60 +10,66 @@
请求进入
│
▼
LogMiddleware::handle()
LogOperation::handle()
│
├─ 记录开始时间 microtime(true)
├─ 反射读取 #[OperationLog] 注解(ReflectionMethod,微秒级)
│
├─ 执行后续中间件和控制器($next($request))
│
├─ 获取响应
│
├─ 检查排除路由(config middleware.log_except)
│ └─ 命中 → 直接返回,不记录
│
├─ 判断是否需要记录
├─ 判断是否需要记录(determineIfShouldLog)
│ ├─ POST/PUT/DELETE/PATCH → 始终记录 ✓
│ ├─ GET + #[Log] TYPE_EXPORT → 记录 ✓
│ ├─ GET + #[Log] TYPE_DOWNLOAD → 记录 ✓
│ ├─ GET + #[Log] TYPE_IMPORT → 记录 ✓
│ └─ 其它 GET → 不记录 ✗
│ ├─ GET + 注解类型为 TYPE_EXPORT(6)/TYPE_DOWNLOAD(16)/TYPE_IMPORT(5) → 记录 ✓
│ └─ 其它 GET / OPTIONS / HEAD → 不记录 ✗
│
├─ 读取 #[Log] 注解(AttributeService::getLog)
│ ├─ 有注解 → 使用注解的 title、type、description
│ └─ 无注解 → 使用路径作为 title,按 HTTP 方法猜测 type
├─ 无需记录 → 直接放行
│
├─ 组装日志数据
│ ├─ 标题、类型(来自注解)
│ ├─ URL、请求参数(截取前 2000 字符)
│ ├─ 响应内容(截取前 2000 字符)
│ ├─ IP、归属地、操作系统、浏览器(RequestInfoService)
│ ├─ 执行耗时 = (当前时间 - 开始时间) × 1000 毫秒
│ └─ 状态(检测响应中的 code:0=正常 1=异常)、错误信息
├─ 收集基础上下文(method、url、param、ip)
│ param = JSON(排除 password/token,截取前 2000 字符)
│
└─ 写入 OperationLog 表(try-catch 包裹,失败不影响主业务)
├─ 注入 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 包裹,失败静默不影响业务)
```
## 记录字段
| 字段 | 说明 | 来源 | 示例 |
|------|------|------|------|
| `title` | 操作标题 | `#[Log]` 注解 title 参数 | 用户管理-新增记录 |
| `type` | 操作类型 | `#[Log]` 注解 type 参数 | 1=新增 |
| `source` | 来源 | 固定 0 | 0=后台 |
| `source` | 来源端 | 固定 0(0=后台 1=前台) | 0 |
| `title` | 操作标题 | `#[OperationLog]` 注解 title 参数,无注解回退请求路径 | 用户管理-新增 |
| `type` | 操作类型 | 注解 type 参数(默认按 HTTP 方法推断) | 1=新增 |
| `method` | HTTP 方法 | 请求方法 | POST |
| `request_method` | 控制器方法名 | 请求 action | add |
| `url` | 请求 URL | 请求 url | /api/user/add |
| `param` | 请求参数 | JSON 截取前 2000 字符 | {"username":"admin"} |
| `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 | 192.168.1.100 |
| `ip` | 客户端 IP | RequestInfoService(经 ProxyMiddleware 还原) | 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(异常时) | 用户名已存在 |
| `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` | 操作时间 | 自动填充 | 2026-09-22 10:30:00 |
| `create_time` | 操作时间 | terminate 阶段写入 | 2026-10-02 10:30:00 |
## 注解使用
@@ -71,190 +77,178 @@ LogMiddleware::handle()
```php
// 写操作(POST/PUT/DELETE/PATCH)始终记录
#[Log('用户管理-新增', Log::TYPE_ADD)]
public function add(): Json { ... }
#[OperationLog('用户管理-新增', OperationLog::TYPE_ADD)]
public function add(): JsonResponse { ... }
#[Log('用户管理-修改', Log::TYPE_UPDATE)]
public function update(): Json { ... }
#[OperationLog('用户管理-修改', OperationLog::TYPE_UPDATE)]
public function update(): JsonResponse { ... }
#[Log('用户管理-删除', Log::TYPE_DELETE)]
public function delete(int $id): Json { ... }
#[OperationLog('用户管理-删除', OperationLog::TYPE_DELETE)]
public function delete(int $id): JsonResponse { ... }
```
### 带描述占位符
```php
#[Log('用户管理-删除记录', Log::TYPE_DELETE, '删除用户ID:{id}')]
public function delete(int $id): Json { ... }
#[OperationLog('用户管理-删除', OperationLog::TYPE_DELETE, '删除用户ID:{id}')]
public function delete(int $id): JsonResponse { ... }
// 占位符 {id} 会被替换为 $request->param('id') 的值
// 占位符 {id} 会被替换为请求参数中 id 的值
// 实际记录:"删除用户ID:42"
#[Log('用户管理-新增记录', Log::TYPE_ADD, '新增用户:{username}')]
public function add(): Json { ... }
#[OperationLog('用户管理-新增', OperationLog::TYPE_ADD, '新增用户:{username}')]
public function add(UserRequest $request): JsonResponse { ... }
// 实际记录:"新增用户:admin"
#[Log('用户管理-批量删除', Log::TYPE_DELETE, '批量删除用户,ID列表:{ids}')]
public function batchDelete(): Json { ... }
// 实际记录:"批量删除用户,ID列表:[1,2,3]"
#[OperationLog('用户管理-批量删除', OperationLog::TYPE_DELETE)]
public function batchDelete(): JsonResponse { ... }
```
### 导出操作(GET 也会记录)
```php
#[Log('用户管理-导出数据', Log::TYPE_EXPORT)]
public function export(): Json { ... }
#[OperationLog('用户管理-导出数据', OperationLog::TYPE_EXPORT)]
public function export(): JsonResponse { ... }
#[Log('用户管理-导入数据', Log::TYPE_IMPORT)]
public function import(): Json { ... }
#[OperationLog('用户管理-导入数据', OperationLog::TYPE_IMPORT, '通过Excel导入用户数据')]
public function import(): JsonResponse { ... }
```
:::warning GET 请求的记录策略
GET 请求仅当注解类型为 `TYPE_EXPORT`、`TYPE_DOWNLOAD` 或 `TYPE_IMPORT` 时才会记录日志。普通查询(`TYPE_QUERY`)不会被记录,以避免大量查询淹没日志。
GET 请求**仅当**注解类型为 `TYPE_IMPORT(5)`、`TYPE_EXPORT(6)`、`TYPE_DOWNLOAD(16)` 时才会记录日志。普通查询(`TYPE_QUERY`)不会被记录,以避免大量查询淹没日志——这也是 `LogOperation::determineIfShouldLog()` 的硬性规则,改注解类型也绕不开。
:::
### 查询类型(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}')]
#[OperationLog('用户管理-新增', OperationLog::TYPE_ADD, '新增用户:{username}')]
#[Permission('sys:user:add', '添加用户')]
public function add(): Json { ... }
public function add(UserRequest $request): JsonResponse { ... }
// 执行顺序:
// 1. AuthMiddleware 校验权限(#[Permission])
// 1. Authenticate 校验权限(#[Permission]),失败返回 403(该次请求不产生日志)
// 2. 权限通过后进入 Controller
// 3. LogMiddleware 记录日志(#[Log])
// 3. LogOperation terminate 阶段记录日志(#[OperationLog])
```
## 操作类型常量
`app/Attributes/OperationLog.php` 共定义 **30 种**操作类型常量:
| 常量 | 值 | 说明 | 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 | 其他 | — |
| `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 | 其他 | — |
完整常量列表见 `app/attribute/Log.php`(共 40 种)。
(`type` 未指定时默认 0,由中间件按 HTTP 方法推断。)
## 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', // 获取用户信息
],
```
| 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/middleware/LogMiddleware.php
// app/Http/Middleware/LogOperation.php(节选)
public function handle(Request $request, \Closure $next): Response
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;
}
$logAttr = $this->getLogAttribute($request); // 反射读取注解
if (! $this->determineIfShouldLog($request->method(), $logAttr)) {
return $next($request);
}
$method = strtoupper($request->method());
$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()), // 无注解按方法推断
];
// 获取注解信息
$controllerClass = 'app\\controller\\' . $request->controller();
$logAttr = AttributeService::getLog($controllerClass, $request->action());
// 请求信息在响应发出前最完整,handle 阶段预解析并写入上下文
$requestInfo = RequestInfoService::create($request);
$context['location'] = $requestInfo->getIpLocation();
$context['os'] = $requestInfo->getOs();
$context['browser'] = $requestInfo->getBrowser();
// 判断是否需要记录
$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 仅记录导出/下载/导入
}
// 上下文挂载到请求属性(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;
}
if (!$needLog) {
return $response;
}
// 取出即删除,防止同一次请求重复写入
$request->attributes->remove(self::CONTEXT_KEY);
try {
$consumeTime = round((microtime(true) - $startTime) * 1000);
$userInfo = $request->userInfo ?? null;
$requestInfo = RequestInfoService::create($request);
$content = $response->getContent();
$statusCode = $this->detectStatus($content);
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 ?? '',
]);
$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) {
// 日志记录失败不影响业务
// 日志写入失败不影响业务
}
return $response;
}
```
:::tip 为什么 handle/terminate 分离?
日志采集需要请求阶段的信息(IP、UA、参数),而写入放在响应之后——既拿到了完整上下文,又不占用业务响应时间。这是 Laravel 中间件 `terminable` 的标准用法。
:::
## 日志查询与分析
### 按操作人查询
@@ -273,7 +267,7 @@ 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
GET /api/oper/log/page?startTime=2026-09-01&endTime=2026-10-02
```
### 按状态查询(查异常操作)
@@ -293,10 +287,10 @@ GET /api/oper/log/page?status=1
```sql
-- 示例:清理 90 天前的操作日志
DELETE FROM think_operation_log WHERE create_time < DATE_SUB(NOW(), INTERVAL 90 DAY);
DELETE FROM laravel_operation_log WHERE create_time < DATE_SUB(NOW(), INTERVAL 90 DAY);
-- 示例:按月归档
SELECT * FROM think_operation_log
SELECT * FROM laravel_operation_log
WHERE create_time >= '2026-09-01' AND create_time < '2026-10-01'
INTO OUTFILE '/backup/operation_log_202609.csv';
```
@@ -305,19 +299,18 @@ INTO OUTFILE '/backup/operation_log_202609.csv';
| 维度 | 操作日志 | 登录日志 |
|------|---------|---------|
| 表 | `think_operation_log` | `think_login_log` |
| 记录方 | LogMiddleware 自动记录 | LoginLogic 手动记录 |
| 记录内容 | 所有写操作 + 导出/导入 | 仅登录/登出 |
| 触发方式 | `#[Log]` 注解 | 代码中调用 `recordLoginLog()` |
| 表 | `laravel_operation_log` | `laravel_login_log` |
| 记录方 | `LogOperation` 中间件(terminate 落库) | `AuthController` 手动记录 |
| 记录内容 | 写操作 + 导出/导入/下载 | 仅登录/登出(成功失败均记) |
| 触发方式 | `#[OperationLog]` 注解 | 代码中 `writeLoginLog()` |
| 用途 | 业务操作审计 | 登录安全审计 |
## 最佳实践
| 实践 | 说明 |
|------|------|
| 标题语义化 | 使用"模块-操作"格式,如"用户管理-新增记录" |
| 标题语义化 | 使用"模块-操作"格式,如"用户管理-新增" |
| 描述加关键参数 | 用 `{param}` 占位符记录业务主键,便于追溯 |
| 敏感操作必须标注 | 删除、重置密码、权限变更等操作必须加 `#[Log]` |
| 查询不记录 | 普通查询不加 `#[Log]` 或使用 `TYPE_QUERY`(不记录) |
| 敏感操作必须标注 | 删除、重置密码、权限变更等操作必须加 `#[OperationLog]` |
| 查询不依赖日志 | 普通查询 `TYPE_QUERY` 不会落库,勿以此做查询审计 |
| 定期清理 | 避免日志表无限增长影响性能 |
| 排除高频接口 | 将频繁调用的接口加入 `log_except` |