首页> 实战笔录 >PHP开发笔记 >ThinkPHP ThinkPHP

TP8使用注解+中间件实现在用户在后台某些特定操作的日志收集

作者:小萝卜 2025-06-25 浏览 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]);
    }
}
 

性能优化建议

虽然中间件内部有注解缓存,但全局中间件会对每个请求执行检查。以下优化措施可提升性能:

  1. 快速跳过检查

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;
}
  1. 使用类级别缓存

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;
    }
    
    // ...继续检查方法注解...
}
  1. 限制反射范围

protected function resolveLoggableAnnotation(string $class, string $action): ?Loggable
{
    // 只检查app/controller命名空间下的类
    if (!str_starts_with($class, 'app\\controller\\')) {
        return null;
    }
    
    // ...原有逻辑...
}

最佳实践建议

  1. 中小型项目:使用纯注解方案,简化开发

  2. 大型高并发项目

    • 使用路由中间件方案控制范围

    • 结合注解缓存和异步队列

  3. 混合使用

// 在中间件中添加
public function handle(Request $request, \Closure $next)
{
    // 优先级1:路由中指定了日志中间件
    if ($request->middleware('loggable')) {
        return $this->processWithLog($request, $next);
    }
    
    // 优先级2:检查注解
    return $this->processWithAnnotation($request, $next);
}

 

 

很赞哦! (0)

文章评论

    高端网站建设