首页> 实战笔录 >PHP开发笔记 >ThinkPHP ThinkPHP
TP8使用注解+中间件实现在用户在后台某些特定操作的日志收集
作者:小萝卜 2025-06-25 【 PHP TP8 注解 中间件 】 浏览 37
简介效果如下:只需在方法上添加 #[Loggable] 注解即可记录日志,无需在路由文件中做任何配置,对业务代码无任何影响 ,可以精确控制哪些方法需要记录日志
ThinkPHP 8 注解中间件日志收集完整方案(无需路由配置)
我将提供完整的代码实现,使用注解中间件方式收集特定方法的操作日志并存入 t_operation_log
表。
CREATE TABLE `operation_log` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`uid` bigint(20) DEFAULT NULL COMMENT '会员id',
`nickname` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '用户名',
`method` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求方式',
`api` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '请求路由',
`router` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '后端路由',
`title` varchar(256) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '业务名称',
`ip` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '请求IP地址',
`ip_country` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '请求IP地址所属国家',
`ip_province` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '请求ip所属省',
`device_id` bigint(20) DEFAULT NULL COMMENT '请求设备唯一标识',
`device_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '请求设备名称',
`browser_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '请求浏览器名称',
`browser_version` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '请求浏览器版本',
`user_agent` varchar(1024) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '请求头',
`client_type` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '设备类型',
`request_params` json DEFAULT NULL COMMENT '请求入参',
`response_params` json DEFAULT NULL COMMENT '出参',
`remark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
`create_time` bigint(20) DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(20) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `user_operation_log_username_index` (`nickname`) USING BTREE,
KEY `index_memberid` (`member_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=88 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='操作日志表';
1. 创建自定义注解类
// app/annotation/Loggable.php
<?php
declare(strict_types=1);
namespace app\annotation;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class Loggable
{
public function __construct(
public string $title = '', // 业务名称
public bool $logParams = true, // 是否记录请求参数
public bool $logResult = false, // 是否记录响应结果
public bool $recordIp = true, // 是否记录IP信息
public bool $recordDevice = true, // 是否记录设备信息
) {}
}
2. 创建操作日志模型
// app/model/OperationLog.php
<?php
namespace app\model;
use think\Model;
class OperationLog extends Model
{
protected $table = 'operation_log';
protected $pk = 'id';
// 自动时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'create_time';
protected $updateTime = 'update_time';
// 设置JSON字段
protected $json = ['request_params', 'response_params'];
protected $jsonAssoc = true;
// 保存前处理
public static function onBeforeWrite($model)
{
// 截断过长的字段
if (is_string($model->user_agent) {
$model->user_agent = mb_substr($model->user_agent, 0, 1024);
}
if (is_string($model->router)) {
$model->router = mb_substr($model->router, 0, 500);
}
if (is_string($model->title)) {
$model->title = mb_substr($model->title, 0, 256);
}
}
}
3. 创建日志中间件
<?php
declare(strict_types=1);
namespace app\admin\middleware;
use app\admin\model\Logs\OperationLog;
use app\admin\model\Member;
use app\annotation\LogOperation;
use ReflectionMethod;
use think\facade\Log;
use think\Request;
use think\Response;
class LogOperationMiddleware
{
// 缓存解析结果,避免重复反射
private static $annotationCache = [];
public function handle($request, \Closure $next)
{
$response = $next($request);
// 获取当前控制器和方法
$controller = $request->controller();
$action = $request->action();
$class = "app\\admin\\controller\\{$controller}";
$class = str_replace('.', '\\', $class);
// 检查缓存
$cacheKey = "{$class}\\{$action}";
if (array_key_exists($cacheKey, self::$annotationCache)) {
$logOperation = self::$annotationCache[$cacheKey];
} else {
$logOperation = $this->resolveLogOperationAnnotation($class, $action);
self::$annotationCache[$cacheKey] = $logOperation;
}
// 如果没有注解,直接返回
if (!$logOperation) {
return $response;
}
// 获取用户信息(根据你的认证系统调整)
try {
$memberId = \tinywan\JWT::getCurrentId();
} catch (\Exception $e) {
$memberId = 1;
}
// 记录开始时间
$startTime = microtime(true);
// 计算执行时间
$executeTime = round((microtime(true) - $startTime) * 1000, 2);
// 保存日志
$this->saveToDatabase($logOperation, $request, $response, $controller, $action, $executeTime ,$memberId,$cacheKey);
return $response;
}
/**
* @param string $class
* @param string $action
* @return LogOperation|null
* @author Luobo
* @date 2025/06/23 12:09
* @desc 解析注解
*/
protected function resolveLogOperationAnnotation(string $class, string $action): ?LogOperation
{
try {
if (!class_exists($class)) {
return null;
}
$reflect = new ReflectionMethod($class, $action);
$attributes = $reflect->getAttributes(LogOperation::class);
if (empty($attributes)) {
return null;
}
// 返回注解实例
return $attributes[0]->newInstance();
} catch (\ReflectionException $e) {
Log::error("日志中间件反射异常: {$class}::{$action} - " . $e->getMessage());
return null;
}
}
/**
* @param LogOperation $logOperation
* @param Request $request
* @param $response
* @param string $controller
* @param string $action
* @param float $executeTime
* @return void
* @author Luobo
* @date 2025/06/23 15:10
* @desc 保存操作日志
*/
protected function saveToDatabase(
LogOperation $logOperation,
Request $request,
$response,
string $controller,
string $action,
float $executeTime,
int $memberId,
string $cacheKey
) {
try {
$nickname = Member::query()->where('id', $memberId)->value('nickname');
// 收集请求信息
$logData = [
'member_id' => $memberId,
'nickname' => $nickname,
'method' => $request->method(),
'api' => '/admin/'.$controller . '/' . $action,
'router' => $cacheKey,
'title' => $logOperation->title ?: ($controller . '@' . $action),
'remark' => "执行耗时: {$executeTime}ms",
];
// 记录请求参数
if ($logOperation->logParams) {
$params = $request->all();
// 敏感信息过滤
$params = $this->filterSensitiveFields($params);
$logData['request_params'] = $params;
}
// 记录响应结果
if ($logOperation->logResult && $response instanceof Response) {
$content = $response->getContent();
// 尝试解析JSON
$logData['response_params'] = json_decode($content, true) ?: $content;
// 如果内容过大,截断
if (is_string($logData['response_params']) && mb_strlen($logData['response_params']) > 2000) {
$logData['response_params'] = mb_substr($logData['response_params'], 0, 2000) . '... [TRUNCATED]';
}
}
// 记录IP和设备信息
if ($logOperation->recordIp || $logOperation->recordDevice) {
$this->addDeviceInfo($logOperation, $request, $logData);
}
// 保存到数据库
$res = OperationLog::create($logData);
} catch (\Throwable $e) {
Log::error('操作日志记录失败: ' . $e->getMessage());
}
}
/**
* @param LogOperation $logOperation
* @param Request $request
* @param array $logData
* @desc 解析请求头中的数据
* @return void
* @author Luobo
* @date 2025/06/23 16:05
*/
protected function addDeviceInfo(LogOperation $logOperation, Request $request, array &$logData)
{
//获取请求头信息
$userAgent = $request->header('user-agent', '');
$logData['user_agent'] = $userAgent;
//获取ip
$ip = getClientIp($request);
$logData['ip'] = $ip ? : 'unknown';
//获取设备信息
$os = getOSInfo($userAgent);
$logData['client_type'] = $os['type'] ? : 'unknown';
$logData['device_name'] = $os['os'] ? : 'unknown';
$logData['browser_name'] = $os['browser_name'] ? : 'unknown';
$logData['browser_version'] = $os['version'] ? : 'unknown';
//获取国家信息
if($ip=='127.0.0.1' || $ip=='::1'){
$logData['ip_country'] = '--';
$logData['ip_province'] = '内网IP';
}else{
$country = getCountryCode($ip);
if($country && is_array($country)){
$logData['ip_country'] = $country['country'] ? : 'unknown';
$logData['ip_province'] = $country['city'] ? : 'unknown';
}
}
}
/**
* @param array $params
* @return array
* @desc 过滤掉敏感信息
* @author Luobo
* @date 2025/06/23 16:30
*/
protected function filterSensitiveFields(array $params): array
{
$sensitiveFields = ['password', 'pay_password', 'token', 'card_number', 'cvv', 'expiry_date'];
foreach ($sensitiveFields as $field) {
if (isset($params[$field])) {
$params[$field] = '******';
}
}
return $params;
}
}
4. 注册中间件
<?php
// 这是系统自动生成的middleware定义文件
return [
app\admin\middleware\LogOperationMiddleware::class,
];
5.定义公共方法获取ip等信息
<?php
use app\admin\model\Config\ConfigServer;
use app\admin\model\Config\Config AS ConfigModel;
use GuzzleHttp\Client;
use think\helper\Arr;
use think\Request;
// 这是系统自动生成的公共文件
if(! function_exists('getClientIp')){
/**
* @param Request $request
* @return string
* @description 获取客户端真实IP地址
* @author Luobo
* @date 2025/3/4 19:21
*/
function getClientIp(Request $request): string
{
// 优先从 X-Forwarded-For 获取(适用于多级代理)
$xForwardedFor = $request->header('x-forwarded-for', '');
if ($xForwardedFor) {
$ips = explode(',', $xForwardedFor);
$clientIp = trim($ips[0]); // 取第一个 IP
if (isValidIp($clientIp)) {
return $clientIp;
}
}
// 其次尝试 X-Real-IP
$xRealIp = $request->header('x-real-ip', '');
if ($xRealIp && isValidIp($xRealIp)) {
return $xRealIp;
}
// 最后回退到 remote_addr
return $request->ip() ?? 'unknown';
}
}
// 验证 IP 合法性
if(! function_exists('isValidIp')) {
function isValidIp(string $ip): bool
{
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6) !== false;
}
}
if (! function_exists('getCountryCode')) {
/**
* 根据IP获取国家代码
* @param $ip
* @return string
* @author Alen
* @date 2025/2/27 14:35
*/
function getCountryCode($ip)
{
if(!$ip){
return '';
}
$client = new Client();
$response = $client->get("http://ip-api.com/json/{$ip}?fields=country,city");
$data = json_decode((string)$response->getBody(), true);
return $data ?? '';
}
}
if (!function_exists('getOSInfo')) {
/**
* 获取设备信息
* @param $agent
* @return array
* @author Alen
* @date 2025/1/12 18:00
*/
function getOSInfo($agent): array
{
if (!isset($agent)) {
return array();
}
$type = 'pc'; //设备类型默认为PC
$version = ''; //系统版本
if (preg_match('/AppleWebKit.*Mobile|Android/i', $agent) || preg_match('/MIDP|SymbianOS|NOKIA|SAMSUNG|LG|NEC|TCL|Alcatel|BIRD|DBTEL|Dopod|PHILIPS|HAIER|LENOVO|MOT-|Nokia|SonyEricsson|SIE-|Amoi|ZTE/i', $agent)) {
//移动设备
$type = 'mobile';
if (strpos($agent, 'iPhone') !== false) {
//iPhone手机
$agent_os = 'iPhone OS';
preg_match("/(?<=CPU iPhone OS )[\d\_]{1,}/", $agent, $match);
if ($match && isset($match[0])) {
$version = str_replace('_', '.', $match[0]);
}
} else if (strpos($agent, 'iPad') !== false) {
//iPad平板
$agent_os = 'iPad OS';
preg_match("/(?<=CPU OS )[\d\_]{1,}/", $agent, $match);
if ($match && isset($match[0])) {
$version = str_replace('_', '.', $match[0]);
}
} else if (strpos($agent, 'Android') !== false) {
//Android设备,手机和平板
preg_match("/(?<=Android )[\d\.]{1,}/", $agent, $match);
$agent_os = 'Android';
if ($match && isset($match[0])) {
$version = $match[0];
}
} else if (strpos($agent, 'Windows Phone')) {
//Window手机操作系统
$agent_os = 'Windows Phone';
$version = 10;
} else if (stripos($agent, 'symbian') !== false) {
//老旧的塞班系统
$agent_os = 'SymbianOS';
} else {
$agent_os = 'other';
}
} else if (strpos($agent, 'Windows') !== false) {
//pc端的windows系统分类
$os_win = array(
'NT 10.0' => 'Windows 10',
'NT 6.4' => 'Windows 10',
'NT 6.3' => 'Windows 8',
'NT 6.2' => 'Windows 8',
'NT 6.1' => 'Windows 7',
'NT 6.0' => 'Windows Vista',
'NT 5.1' => 'Windows XP',
'NT 5.0' => 'Windows 2000',
'NT' => 'Windows NT',
);
$agent_os = 'Windows';
foreach ($os_win as $core => $os) {
if (stripos($agent, $core) !== false) {
$agent_os = $os;
break;
}
}
} else if (stripos($agent, 'mac') !== false) {
$agent_os = 'Mac OS';
} else if (stripos($agent, 'ubuntu') !== false) {
$agent_os = 'Ubuntu';
} else if (stripos($agent, 'debian') !== false) {
$agent_os = 'Debian';
} else if (stripos($agent, 'linux') !== false) {
$agent_os = 'Linux';
} else {
$agent_os = 'other';
}
return ['type' => $type, 'os' => $agent_os, 'version' => $version,'browser_name' => getBrowserName($agent)];
}
}
// getBrowserName
if(!function_exists('getBrowserName')){
function getBrowserName(string $userAgent = ''): string
{
// 常见浏览器标识正则匹配
$browsers = [
'/Edg/i' => 'Edge',
'/MSIE|Trident/i' => 'Internet Explorer',
'/Firefox/i' => 'Firefox',
'/OPR|Opera/i' => 'Opera',
'/Chrome|CriOS/i' => 'Chrome',
'/Safari/i' => 'Safari',
];
foreach ($browsers as $pattern => $browser) {
if (preg_match($pattern, $userAgent)) {
return $browser;
}
}
return 'unknown';
}
}
if(! function_exists('get_partner_host')){
/**
* 获取合作伙伴链接域名
* @return array
* @author Luobo
* @date 2025/3/01 11:35
*/
function get_partner_host($serverId = 0){
$host = getConfigValue('PARTNER_HOST',$serverId);
$hosts = explode(',',$host);
if(empty($hosts)) return [];
return collect($hosts)->map(function($item){
$item = explode('|',$item);
$notes = Arr::get($item,'2');
return [
'label' => Arr::get($item,'0'),
'value' => Arr::get($item,'1'),
'notes' => $notes?lang($notes):NULL
];
})->toArray();
}
}
if (! function_exists('getConfigValue')) {
/**
* 获取系统配置信息
* @param $key
* @param null $default
* @return mixed
* @author Alen
* @date 2025/1/12 16:47
*/
function getConfigValue($key, $serverId = 0)
{
if($serverId > 0){
$configVal = ConfigServer::findByFilter([
'configname' => $key,
'server_id' => $serverId,
]);
if($configVal){
return $configVal->configvalue;
}
}
$configVal = ConfigModel::findByFilter([
'name' => $key,
'status' => "1",
]);
if($configVal){
return $configVal->value;
}
return null;
}
}
6. 在控制器中使用注解
// app/controller/User.php
<?php
namespace app\controller;
use app\annotation\Loggable;
use think\Response;
class User
{
#[Loggable(
title: "用户登录",
logParams: true,
recordIp: true,
recordDevice: true
)]
public function login(): Response
{
// 业务逻辑
return json(['code' => 200, 'msg' => '登录成功']);
}
#[Loggable(
title: "获取用户信息",
logResult: true,
recordIp: true
)]
public function info(int $id): Response
{
$user = ['id' => $id, 'name' => '张三'];
return json($user);
}
// 此方法不会记录日志
public function publicProfile(int $id): Response
{
// 公开信息
return json(['id' => $id, 'public' => true]);
}
}
性能优化建议
虽然中间件内部有注解缓存,但全局中间件会对每个请求执行检查。以下优化措施可提升性能:
-
快速跳过检查:
public function handle(Request $request, \Closure $next)
{
// 快速检查:如果当前请求是静态资源等,直接跳过
if ($this->shouldSkip($request)) {
return $next($request);
}
// ...原有逻辑...
}
protected function shouldSkip(Request $request): bool
{
// 跳过静态资源请求
if (preg_match('/\.(js|css|jpg|png|gif)$/i', $request->pathinfo())) {
return true;
}
// 跳过特定路由
$skipRoutes = ['/healthcheck', '/ping'];
if (in_array($request->pathinfo(), $skipRoutes)) {
return true;
}
return false;
}
-
使用类级别缓存:
private static $classAnnotationCache = [];
protected function resolveLoggableAnnotation(string $class, string $action): ?Loggable
{
// 先检查类是否有可能有注解方法
if (!isset(self::$classAnnotationCache[$class])) {
try {
$classReflect = new \ReflectionClass($class);
self::$classAnnotationCache[$class] = !empty($classReflect->getAttributes(Loggable::class));
} catch (\ReflectionException $e) {
self::$classAnnotationCache[$class] = false;
}
}
// 如果类没有注解,直接返回null
if (!self::$classAnnotationCache[$class])) {
return null;
}
// ...继续检查方法注解...
}
-
限制反射范围:
protected function resolveLoggableAnnotation(string $class, string $action): ?Loggable
{
// 只检查app/controller命名空间下的类
if (!str_starts_with($class, 'app\\controller\\')) {
return null;
}
// ...原有逻辑...
}
最佳实践建议
-
中小型项目:使用纯注解方案,简化开发
-
大型高并发项目:
-
使用路由中间件方案控制范围
-
结合注解缓存和异步队列
-
-
混合使用:
// 在中间件中添加
public function handle(Request $request, \Closure $next)
{
// 优先级1:路由中指定了日志中间件
if ($request->middleware('loggable')) {
return $this->processWithLog($request, $next);
}
// 优先级2:检查注解
return $this->processWithAnnotation($request, $next);
}
很赞哦! (0)
下一篇:TP6插入数据自动写入时间