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

作者:mdo 发布时间: 2025-11-10 阅读量:11

我将提供完整的代码实现,使用注解中间件方式收集特定方法的操作日志并存入 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. 创建自定义注解类

<?php
declare(strict_types=1);

namespace appannotation;

use Attribute;


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. 创建操作日志模型

<?php
namespace appmodel;

use thinkModel;

class OperationLog extends Model
{
    protected $table = 'operation_log';
    protected $pk = 'id';


    protected $autoWriteTimestamp = true;
    protected $createTime = 'create_time';
    protected $updateTime = 'update_time';


    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 appadminmiddleware;

use appadminmodelLogsOperationLog;
use appadminmodelMember;
use appannotationLogOperation;
use ReflectionMethod;
use thinkfacadeLog;
use thinkRequest;
use thinkResponse;

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 = tinywanJWT::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;
    }


    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;
        }
    }


    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();

                $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]';
                }
            }


            if ($logOperation->recordIp || $logOperation->recordDevice) {
                $this->addDeviceInfo($logOperation, $request, $logData);
            }


            $res = OperationLog::create($logData);

        } catch (Throwable $e) {
            Log::error('操作日志记录失败: ' . $e->getMessage());
        }
    }


    protected function addDeviceInfo(LogOperation $logOperation, Request $request, array &$logData)
    {

        $userAgent = $request->header('user-agent', '');

        $logData['user_agent'] = $userAgent;


        $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';
            }
        }

    }


    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. 注册中间件

 ```null <?php

return [ appadminmiddlewareLogOperationMiddleware::class, ];

&nbsp;

### 5.定义公共方法获取ip等信息

&nbsp;```null
<?php

use appadminmodelConfigConfigServer;
use appadminmodelConfigConfig AS ConfigModel;
use GuzzleHttpClient;
use thinkhelperArr;
use thinkRequest;



if(! function_exists('getClientIp')){

    function getClientIp(Request $request): string
    {

        $xForwardedFor = $request->header('x-forwarded-for', '');
        if ($xForwardedFor) {
            $ips = explode(',', $xForwardedFor);
            $clientIp = trim($ips[0]); 
            if (isValidIp($clientIp)) {
                return $clientIp;
            }
        }


        $xRealIp = $request->header('x-real-ip', '');
        if ($xRealIp && isValidIp($xRealIp)) {
            return $xRealIp;
        }


        return $request->ip() ?? 'unknown';
    }
}


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')) {

    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')) {

    function getOSInfo($agent): array
    {
        if (!isset($agent)) {
            return array();
        }

        $type = '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) {

                $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) {

                $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) {

                preg_match("/(?<=Android )[d.]{1,}/", $agent, $match);
                $agent_os = 'Android';
                if ($match && isset($match[0])) {
                    $version = $match[0];
                }
            } else if (strpos($agent, 'Windows Phone')) {

                $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) {

            $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)];
    }
}


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')){

    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')) {

    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. 在控制器中使用注解

 ```null

<?php namespace appcontroller;

use appannotationLoggable; use thinkResponse;

class User {

    title: "用户登录", 
    logParams: true, 
    recordIp: true,
    recordDevice: true
)]
public function login(): Response
{

    return json(['code' => 200, 'msg' => '登录成功']);
}


    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]);
}

}

&nbsp;

### 性能优化建议

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

1.  **快速跳过检查**:

null 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;

}

2.  **使用类级别缓存**:

null 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;
    }
}


if (!self::$classAnnotationCache[$class])) {
    return null;
}

}

3.  **限制反射范围**:

null protected function resolveLoggableAnnotation(string $class, string $action): ?Loggable {

if (!str_starts_with($class, 'app\controller\')) {
    return null;
}

}

### 最佳实践建议

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

2.  **大型高并发项目**:

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

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

3.  **混合使用**:

null

public function handle(Request $request, Closure $next) {

if ($request->middleware('loggable')) {
    return $this->processWithLog($request, $next);
}


return $this->processWithAnnotation($request, $next);

} ```