diff --git a/CONTROLLER_ANNOTATION_EXAMPLE.md b/CONTROLLER_ANNOTATION_EXAMPLE.md new file mode 100644 index 0000000..a77ef4b --- /dev/null +++ b/CONTROLLER_ANNOTATION_EXAMPLE.md @@ -0,0 +1,385 @@ +# 控制器方法注释示例 + +think-plugs-recorder 中间件可以自动读取控制器方法的注释来获取准确的操作类型和描述,提供更精确的操作记录。 + +## 支持的注释格式 + +### 1. 使用专用注解 + +#### @operation - 推荐使用 +```php +读取, POST->创建等) + +## 注意事项 + +1. **注释格式**: 使用标准的PHPDoc格式注释 +2. **操作类型**: 建议使用中文操作类型,更直观 +3. **描述长度**: 操作描述建议控制在100字符以内 +4. **关键词匹配**: 操作类型会自动匹配预定义关键词 +5. **性能考虑**: 注释解析使用反射,有轻微性能开销,但可接受 + +## 配置示例 + +如果要在项目中全面使用注释驱动的操作记录,建议的配置: + +```php +// config/recorder.php +return [ + 'enabled' => true, + 'auto_record' => [ + 'enabled' => true, + 'exclude_operations' => [], // 不排除任何操作,全部记录 + 'exclude_controllers' => ['captcha', 'upload'], // 排除验证码和上传控制器 + ], + // ...其他配置 +]; +``` + +通过合理使用方法注释,可以让操作记录更加准确和有意义,为系统审计和分析提供更好的数据支持。 \ No newline at end of file diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..35bcc0c --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,507 @@ +# think-plugs-recorder 插件设计文档 + +## 1. 功能需求分析 + +### 1.1 项目概述 +think-plugs-recorder 是一个基于 ThinkAdmin v6.1 的操作记录插件,用于记录用户在系统中的各种操作行为,提供完整的操作日志追踪功能。 + +### 1.2 核心功能需求 + +#### 1.2.1 操作记录功能 +- 记录用户的读取操作 +- 记录用户的自定义操作 +- 支持记录操作数据的类型和ID +- 支持记录关联数据的类型和ID + +#### 1.2.2 记录字段要求 +1. **操作类型** (operation_type): 如 create, read, update, delete, custom 等 +2. **操作说明** (operation_desc): 具体的操作描述 +3. **用户ID** (user_id): 操作用户的唯一标识 +4. **用户昵称** (user_nickname): 操作用户的显示名称 +5. **操作时间** (operation_time): 操作发生的时间戳 +6. **当前操作数据类型** (data_type): 如 user, order, product 等 +7. **当前操作数据ID** (data_id): 具体数据记录的ID +8. **关联数据类型** (related_type): 关联数据的类型(可选) +9. **关联数据ID** (related_id): 关联数据的ID(可选) + +#### 1.2.3 功能扩展需求 +- 支持批量记录操作 +- 支持按多种条件查询操作记录 +- 支持操作记录的导出功能 +- 支持自定义操作类型配置 +- 支持敏感操作的特殊标记 + +### 1.3 非功能性需求 +- **性能要求**: 记录操作不应影响主业务性能 +- **存储要求**: 支持大量历史记录存储 +- **安全要求**: 操作记录不可被随意修改或删除 +- **可维护性**: 提供清晰的接口和配置选项 + +## 2. 数据库设计 + +### 2.1 主要数据表设计 + +#### 2.1.1 操作记录表 (jl_recorder_log) + +```sql +CREATE TABLE `jl_recorder_log` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '记录ID', + `operation_type` varchar(50) NOT NULL DEFAULT '' COMMENT '操作类型', + `operation_desc` varchar(500) NOT NULL DEFAULT '' COMMENT '操作说明', + `user_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '操作用户ID', + `user_nickname` varchar(100) NOT NULL DEFAULT '' COMMENT '操作用户昵称', + `data_type` varchar(100) NOT NULL DEFAULT '' COMMENT '操作数据类型', + `data_id` varchar(100) NOT NULL DEFAULT '' COMMENT '操作数据ID', + `related_type` varchar(100) NOT NULL DEFAULT '' COMMENT '关联数据类型', + `related_id` varchar(100) NOT NULL DEFAULT '' COMMENT '关联数据ID', + `request_method` varchar(10) NOT NULL DEFAULT '' COMMENT '请求方法', + `request_url` varchar(500) NOT NULL DEFAULT '' COMMENT '请求URL', + `request_ip` varchar(50) NOT NULL DEFAULT '' COMMENT '请求IP', + `user_agent` varchar(500) NOT NULL DEFAULT '' COMMENT '用户代理', + `extra_data` text COMMENT '额外数据(JSON格式)', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_operation_type` (`operation_type`), + KEY `idx_data_type_id` (`data_type`, `data_id`), + KEY `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='操作记录表'; +``` + +#### 2.1.2 预置操作类型说明 +操作类型直接使用中文字符串,常用的操作类型包括: +- **创建**: 新增数据记录 +- **读取**: 查看数据详情 +- **更新**: 修改数据记录 +- **删除**: 删除数据记录 +- **导出**: 导出数据 +- **登录**: 用户登录 +- **登出**: 用户登出 +- **审核**: 审核操作 +- **发布**: 发布操作 + +### 2.2 索引设计说明 +- `idx_user_id`: 按用户查询操作记录 +- `idx_operation_type`: 按操作类型查询 +- `idx_data_type_id`: 按数据类型和ID查询特定数据的操作记录 +- `idx_created_at`: 按时间范围查询操作记录 + +## 3. 架构设计 + +### 3.1 插件架构 +``` +think-plugs-recorder/ +├── composer.json # Composer配置 +├── DESIGN.md # 设计文档 +├── README.md # 使用说明 +├── src/ # 源码目录 +│ ├── Service.php # 插件服务类 +│ ├── model/ # 模型目录 +│ │ └── RecorderLog.php # 操作记录模型 +│ ├── service/ # 服务目录 +│ │ └── RecorderService.php # 核心服务类 +│ ├── controller/ # 控制器目录 (可选) +│ │ └── RecorderController.php # 记录管理控制器 +│ ├── middleware/ # 中间件目录 +│ │ └── RecorderMiddleware.php # 操作记录中间件 +│ └── helper/ # 辅助工具目录 +│ └── ViewHelper.php # 视图辅助类 +├── stc/ # 静态资源 +│ └── database/ # 数据库迁移脚本 +│ └── 20241201000001_create_recorder_logs_table.php +└── view/ # 视图文件 + └── recorder/ + ├── components/ # 视图组件 + │ ├── record_list.html # 记录列表组件 + │ ├── record_item.html # 记录项组件 + │ └── record_timeline.html # 时间线组件 + ├── index.html # 管理页面 + └── detail.html # 详情页面 +``` + +### 3.2 核心类设计 + +#### 3.2.1 RecorderService 核心服务类 +```php + 'integer', + 'user_id' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + // 字段验证规则 + protected function getRules(): array; + + + // 获取额外数据 + public function getExtraDataAttr($value): array; + + // 设置额外数据 + public function setExtraDataAttr($value): string; +} +``` + +#### 3.2.3 ViewHelper 视图辅助类 +```php + '读取', + 'operation_desc' => '查看用户信息', + 'data_type' => 'user', + 'data_id' => '123', + 'related_type' => 'department', + 'related_id' => '456' +]); + +// 自动记录当前用户信息 +RecorderService::autoRecord([ + 'operation_type' => '创建', + 'operation_desc' => '创建新订单', + 'data_type' => 'order', + 'data_id' => '789' +]); +``` + +#### 3.3.2 查询接口 +```php +// 查询用户操作记录 +$records = RecorderService::getUserRecords(123, [ + 'operation_type' => '读取', + 'start_time' => '2024-01-01', + 'end_time' => '2024-12-31' +]); + +// 查询数据操作记录 +$records = RecorderService::getDataRecords('user', '123'); + +// 复杂条件查询 +$records = RecorderService::query([ + 'user_id' => 123, + 'operation_type' => ['读取', '更新'], + 'data_type' => 'user', + 'date_range' => ['2024-01-01', '2024-12-31'] +]); +``` + +#### 3.3.3 视图调用接口 +```php +// 在模板中获取操作记录 +{:recorder_get_records(['data_type' => 'user', 'data_id' => $user_id])} + +// 渲染记录列表 +{:recorder_render_list(['user_id' => $user_id], ['limit' => 10])} + +// 渲染时间线 +{:recorder_render_timeline(['data_type' => 'order', 'data_id' => $order_id])} + +// 渲染单条记录 +{:recorder_render_item($record)} +``` + +## 4. 使用方式设计 + +### 4.1 手动记录 +在业务代码中手动调用记录方法: +```php +use jerryyan\recorder\service\RecorderService; + +// 在控制器或服务中记录操作 +RecorderService::record([ + 'operation_type' => '读取', + 'operation_desc' => '查看用户详情', + 'data_type' => 'user', + 'data_id' => $userId +]); +``` + +### 4.2 中间件自动记录 +通过中间件自动记录请求操作: +```php +// 在应用配置中启用中间件 +'middleware' => [ + 'jerryyan\recorder\middleware\RecorderMiddleware' +] +``` + +### 4.3 模型事件记录 +在模型事件中自动记录CRUD操作: +```php +// 在模型中添加事件监听 +protected static function onAfterInsert($model) { + RecorderService::autoRecord([ + 'operation_type' => '创建', + 'operation_desc' => '创建' . $model->getTable() . '记录', + 'data_type' => $model->getTable(), + 'data_id' => $model->getPk() + ]); +} +``` + +### 4.4 视图模板调用 +在ThinkPHP模板中直接调用操作记录功能: + +#### 4.4.1 获取记录数据 +```html + +{assign name="user_records" value=":recorder_get_records(['user_id' => $user_id, 'limit' => 5])" /} + + +{assign name="data_records" value=":recorder_get_records(['data_type' => 'order', 'data_id' => $order_id])" /} + + +{volist name="user_records" id="record"} +
+ {$record.operation_type} + {$record.operation_desc} + {$record.created_at} +
+{/volist} +``` + +#### 4.4.2 渲染HTML组件 +```html + +
+

操作记录

+ {:recorder_render_list(['user_id' => $user_id], ['limit' => 10, 'show_user' => false])} +
+ + +
+

操作时间线

+ {:recorder_render_timeline(['data_type' => 'order', 'data_id' => $order_id], ['theme' => 'vertical'])} +
+ + +
+
最近操作
+ {:recorder_render_list(['user_id' => $user_id], ['limit' => 3, 'compact' => true])} +
+``` + +#### 4.4.3 AJAX异步加载 +```html + +
+
+ {:recorder_render_list(['data_type' => 'user', 'data_id' => $user_id], ['limit' => 10])} +
+ +
+ + +``` + +#### 4.4.4 可配置的显示选项 +```html + +{assign name="list_options" value="[ + 'limit' => 20, + 'show_user' => true, + 'show_ip' => false, + 'date_format' => 'Y-m-d H:i:s', + 'theme' => 'card', + 'show_extra' => false +]" /} + +{:recorder_render_list(['user_id' => $user_id], $list_options)} + + +{:recorder_render_timeline(['data_type' => 'project', 'data_id' => $project_id], ['compact' => true, 'limit' => 5])} +``` + +## 5. 配置设计 + +### 5.1 插件配置 +```php +return [ + 'recorder' => [ + // 是否启用操作记录 + 'enabled' => true, + + // 自动记录模式 + 'auto_record' => [ + 'enabled' => true, + 'exclude_operations' => ['读取'], // 排除的操作类型 + 'exclude_controllers' => [], // 排除的控制器 + ], + + // 记录保留天数 + 'retention_days' => 90, + + // 敏感操作标记 + 'sensitive_operations' => ['删除', '导出'], + + // 视图组件配置 + 'view' => [ + 'default_limit' => 10, + 'date_format' => 'Y-m-d H:i:s', + 'theme' => 'default', // default, card, timeline + 'show_user' => true, + 'show_ip' => false, + 'compact_mode' => false, + ] + ] +]; +``` + +## 6. 安全考虑 + +### 6.1 数据安全 +- 记录表只允许插入和查询,不允许更新和删除 +- 敏感操作需要特殊权限才能查看 +- 支持数据脱敏显示 + +### 6.2 性能考虑 +- 异步记录操作日志,不影响主业务 +- 定期清理过期日志 +- 合理的索引设计 + +### 6.3 权限控制 +- 基于 ThinkAdmin 的权限系统 +- 不同用户只能查看自己的操作记录 +- 管理员可以查看所有用户的操作记录 + +## 7. 扩展计划 + +### 7.1 功能扩展 +- 支持操作记录的统计分析 +- 支持异常操作的告警机制 +- 支持操作记录的可视化展示 + +### 7.2 集成扩展 +- 支持与其他日志系统集成 +- 支持导出到第三方审计系统 +- 支持API接口调用记录 + +### 7.3 视图功能扩展 +- 支持更多主题样式和布局 +- 支持自定义模板和组件 +- 支持实时刷新和WebSocket推送 +- 支持操作记录的图表统计 + +## 8. 视图模板函数设计 + +### 8.1 注册的模板函数 +插件将自动注册以下模板函数供视图调用: + +```php +// 获取操作记录数据 +function recorder_get_records(array $conditions = []): array + +// 渲染记录列表 +function recorder_render_list(array $conditions = [], array $options = []): string + +// 渲染时间线 +function recorder_render_timeline(array $conditions = [], array $options = []): string + +// 渲染单条记录 +function recorder_render_item(array $record, array $options = []): string + +// 获取记录统计 +function recorder_get_stats(array $conditions = []): array +``` + +### 8.2 可用的显示选项 +```php +$options = [ + 'limit' => 10, // 显示数量限制 + 'show_user' => true, // 是否显示用户信息 + 'show_ip' => false, // 是否显示IP地址 + 'show_extra' => false, // 是否显示额外数据 + 'date_format' => 'Y-m-d H:i:s', // 日期格式 + 'theme' => 'default', // 主题样式 (default, card, timeline, compact) + 'compact' => false, // 是否紧凑模式 + 'css_class' => '', // 自定义CSS类 + 'template' => '', // 自定义模板文件 +]; +``` + +### 8.3 视图组件样式 +插件提供多种预置样式主题: +- **default**: 默认列表样式 +- **card**: 卡片式布局 +- **timeline**: 时间线布局 +- **compact**: 紧凑式布局 + +--- + +**注意**: 此设计文档需要在开始编码前经过确认,确保需求理解准确无误。 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..58532bc --- /dev/null +++ b/README.md @@ -0,0 +1,388 @@ +# ThinkAdmin 操作记录插件 + +基于 ThinkAdmin v6.1 的操作记录插件,提供完整的用户操作追踪功能,支持手动记录和自动记录,包含丰富的视图展示功能。 + +## 功能特性 + +### 核心功能 +- ✅ **灵活记录**: 支持手动记录和自动记录两种模式 +- ✅ **完整追踪**: 记录操作类型、用户信息、数据信息、时间等 +- ✅ **关联数据**: 支持记录操作数据和关联数据 +- ✅ **视图集成**: 提供模板函数,可在视图中直接调用 +- ✅ **多种样式**: 支持列表、卡片、时间线等多种展示样式 +- ✅ **自动清理**: 支持定期清理过期记录 + +### 记录内容 +- 操作类型:创建、读取、更新、删除、导出、登录、登出等 +- 操作说明:具体的操作描述 +- 用户信息:用户ID、用户昵称 +- 数据信息:操作的数据类型和ID +- 关联信息:关联的数据类型和ID(可选) +- 请求信息:请求方法、URL、IP地址、用户代理 +- 额外数据:JSON格式的扩展信息 + +## 安装配置 + +### 1. 数据库迁移 +```bash +# 运行数据库迁移脚本 +php think migrate:run +``` + +### 2. 插件发布 +```bash +# 发布插件资源 +php think xadmin:publish --migrate +``` + +### 3. 配置文件 +在 `config/` 目录下创建 `recorder.php` 配置文件: + +```php + true, + + // 自动记录配置 + 'auto_record' => [ + 'enabled' => true, + 'exclude_operations' => ['读取'], // 排除的操作类型 + 'exclude_controllers' => [], // 排除的控制器 + ], + + // 记录保留天数 + 'retention_days' => 90, + + // 敏感操作标记 + 'sensitive_operations' => ['删除', '导出'], + + // 视图组件配置 + 'view' => [ + 'default_limit' => 10, + 'date_format' => 'Y-m-d H:i:s', + 'theme' => 'default', + 'show_user' => true, + 'show_ip' => false, + 'compact_mode' => false, + ] +]; +``` + +### 4. 注册中间件(可选) +在 `app/middleware.php` 中注册中间件实现自动记录: + +```php + '创建', + 'operation_desc' => '创建新用户', + 'data_type' => 'user', + 'data_id' => '123' +]); + +// 完整记录 +RecorderService::record([ + 'operation_type' => '更新', + 'operation_desc' => '修改用户信息', + 'user_id' => 1, + 'user_nickname' => '管理员', + 'data_type' => 'user', + 'data_id' => '123', + 'related_type' => 'department', + 'related_id' => '456', + 'extra_data' => ['old_name' => '张三', 'new_name' => '李四'] +]); + +// 自动记录(自动获取当前用户和请求信息) +RecorderService::autoRecord([ + 'operation_type' => '导出', + 'operation_desc' => '导出用户列表', + 'data_type' => 'user' +]); +``` + +### 2. 查询操作记录 + +```php +use jerryyan\recorder\service\RecorderService; + +// 查询用户操作记录 +$userRecords = RecorderService::getUserRecords(123, [ + 'limit' => 10, + 'operation_type' => '创建' +]); + +// 查询数据操作记录 +$dataRecords = RecorderService::getDataRecords('user', '123'); + +// 复杂查询 +$records = RecorderService::query([ + 'user_id' => 123, + 'operation_type' => ['创建', '更新'], + 'data_type' => 'user', + 'start_time' => '2024-01-01', + 'end_time' => '2024-12-31', + 'keyword' => '用户管理' +]); + +// 获取统计数据 +$stats = RecorderService::getStats([ + 'start_time' => '2024-01-01', + 'end_time' => '2024-12-31' +]); +``` + +### 3. 视图中调用 + +**注意**: 如果提示函数未定义,请参考 [模板函数使用指南](TEMPLATE_FUNCTIONS_GUIDE.md) 解决。 + +首先确保函数已正确注册: +```bash +# 更新Composer自动加载 +composer dump-autoload +``` + +#### 3.1 获取记录数据 +```html + +{assign name="records" value=":recorder_get_records(['user_id' => $user_id, 'limit' => 5])" /} + + +{volist name="records" id="record"} +
+ {$record.operation_type} - {$record.operation_desc} - {$record.created_at} +
+{/volist} +``` + +#### 3.2 渲染HTML组件 +```html + +
+

操作记录

+ {:recorder_render_list(['user_id' => $user_id], ['limit' => 10])} +
+ + +{:recorder_render_list(['data_type' => 'order'], ['theme' => 'card', 'show_user' => true])} + + +{:recorder_render_timeline(['data_type' => 'project', 'data_id' => $project_id])} + + +{:recorder_render_item($record, ['show_extra' => true])} +``` + +#### 3.3 配置选项 +```html + +{assign name="options" value="[ + 'limit' => 20, + 'show_user' => true, + 'show_ip' => false, + 'date_format' => 'Y-m-d H:i:s', + 'theme' => 'card', + 'compact' => false, + 'css_class' => 'my-custom-class' +]" /} + +{:recorder_render_list(['user_id' => $user_id], $options)} +``` + +#### 3.4 备用方案(如果函数未注册) +```html + +{assign name="records" value=":jerryyan\recorder\helper\ViewHelper::getRecords(['user_id' => $user_id])" /} + + +{:jerryyan\recorder\helper\ViewHelper::renderList(['user_id' => $user_id], ['limit' => 10])} + + +{:jerryyan\recorder\helper\ViewHelper::renderTimeline(['data_type' => 'order', 'data_id' => $order_id])} +``` + +### 4. 模型事件自动记录 + +```php +use jerryyan\recorder\service\RecorderService; + +class User extends \think\admin\Model +{ + // 创建后自动记录 + protected static function onAfterInsert($user) + { + RecorderService::autoRecord([ + 'operation_type' => '创建', + 'operation_desc' => '创建用户:' . $user->username, + 'data_type' => 'user', + 'data_id' => $user->id + ]); + } + + // 更新后自动记录 + protected static function onAfterUpdate($user) + { + RecorderService::autoRecord([ + 'operation_type' => '更新', + 'operation_desc' => '更新用户:' . $user->username, + 'data_type' => 'user', + 'data_id' => $user->id + ]); + } +} +``` + +### 5. 数据管理 + +```php +use jerryyan\recorder\service\RecorderService; + +// 导出记录 +$filepath = RecorderService::export([ + 'start_time' => '2024-01-01', + 'end_time' => '2024-12-31' +]); + +// 清理过期记录 +$deletedCount = RecorderService::cleanup(90); // 清理90天前的记录 +``` + +## 视图样式 + +插件提供多种内置样式: + +### 1. 默认列表样式 +简洁的列表展示,适合在管理页面中使用。 + +### 2. 卡片样式 +美观的卡片布局,适合在首页或仪表板中展示。 + +### 3. 时间线样式 +时间线形式展示,清晰展示操作的时间顺序。 + +### 4. 紧凑样式 +紧凑的显示模式,适合空间有限的场景。 + +## 数据库结构 + +插件使用单表存储所有操作记录: + +```sql +CREATE TABLE `jl_recorder_log` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '记录ID', + `operation_type` varchar(50) NOT NULL DEFAULT '' COMMENT '操作类型', + `operation_desc` varchar(500) NOT NULL DEFAULT '' COMMENT '操作说明', + `user_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '操作用户ID', + `user_nickname` varchar(100) NOT NULL DEFAULT '' COMMENT '操作用户昵称', + `data_type` varchar(100) NOT NULL DEFAULT '' COMMENT '操作数据类型', + `data_id` varchar(100) NOT NULL DEFAULT '' COMMENT '操作数据ID', + `related_type` varchar(100) NOT NULL DEFAULT '' COMMENT '关联数据类型', + `related_id` varchar(100) NOT NULL DEFAULT '' COMMENT '关联数据ID', + `request_method` varchar(10) NOT NULL DEFAULT '' COMMENT '请求方法', + `request_url` varchar(500) NOT NULL DEFAULT '' COMMENT '请求URL', + `request_ip` varchar(50) NOT NULL DEFAULT '' COMMENT '请求IP', + `user_agent` varchar(500) NOT NULL DEFAULT '' COMMENT '用户代理', + `extra_data` text COMMENT '额外数据(JSON格式)', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_operation_type` (`operation_type`), + KEY `idx_data_type_id` (`data_type`, `data_id`), + KEY `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='操作记录表'; +``` + +## 性能优化 + +1. **索引优化**: 针对常用查询条件建立了合适的数据库索引 +2. **异步记录**: 记录操作不会影响主业务的性能 +3. **数据限制**: 自动限制存储数据的长度,避免过大数据影响性能 +4. **定期清理**: 支持自动清理过期记录,保持表的性能 + +## 安全考虑 + +1. **数据脱敏**: 自动过滤敏感信息如密码、密钥等 +2. **权限控制**: 基于ThinkAdmin的权限系统 +3. **只读模式**: 操作记录支持只读模式,防止恶意修改 +4. **错误隔离**: 记录失败不会影响主业务逻辑 + +## 常见问题 + +### Q: 插件安装后没有生效怎么办? +A: 检查以下几点: +1. 确认数据库迁移是否成功执行 +2. 检查配置文件是否正确 +3. 确认插件是否已经正确注册 + +### Q: 如何自定义操作类型? +A: 操作类型由代码直接控制,可以使用任意中文字符串作为操作类型。 + +### Q: 如何实现自定义的视图样式? +A: 可以通过以下方式: +1. 在配置中指定自定义CSS类 +2. 创建自定义模板文件 +3. 修改内置的CSS样式 + +### Q: 记录过多会影响性能吗? +A: 插件已经进行了性能优化: +1. 使用了合适的数据库索引 +2. 支持定期清理过期记录 +3. 异步记录不影响主业务 + +## 更新日志 + +### v1.0.0 (2024-12-12) +- 🎉 初始版本发布 +- ✅ 支持手动和自动操作记录 +- ✅ 提供完整的视图展示功能 +- ✅ 支持多种展示样式 +- ✅ 包含中间件自动记录功能 + +## 许可证 + +WTFPL + +## 作者 + +Jerry Yan (792602257@qq.com) + +## 技术支持 + +如有问题,请提交 Issue 或联系作者。 \ No newline at end of file diff --git a/TEMPLATE_FUNCTIONS_GUIDE.md b/TEMPLATE_FUNCTIONS_GUIDE.md new file mode 100644 index 0000000..3825402 --- /dev/null +++ b/TEMPLATE_FUNCTIONS_GUIDE.md @@ -0,0 +1,193 @@ +# 模板函数使用指南 + +如果在使用模板函数时提示 `recorder_get_records` 等函数未定义,请按照以下步骤解决: + +## 问题原因 + +模板函数需要在ThinkPHP启动时正确注册到全局作用域,可能因为以下原因导致函数未注册: +1. Composer自动加载未正确更新 +2. 插件服务未正确启动 +3. 函数文件未被正确加载 + +## 解决方案 + +### 方案1: 更新Composer自动加载(推荐) + +在项目根目录执行: +```bash +composer dump-autoload +``` + +这会重新生成自动加载文件,确保 `src/functions.php` 被正确加载。 + +### 方案2: 手动引入函数文件 + +#### 在应用启动时引入 +在 `app/provider.php` 或 `config/app.php` 中添加: + +```php +// 引入操作记录模板函数 +$functionsFile = app_path('plugs/think-plugs-recorder/src/functions.php'); +if (file_exists($functionsFile)) { + require_once $functionsFile; +} +``` + +#### 在模板中临时引入 +在需要使用的模板文件顶部添加: + +```html +{php} +$functionsFile = app_path('plugs/think-plugs-recorder/src/functions.php'); +if (file_exists($functionsFile)) { + require_once $functionsFile; +} +{/php} + + +{:recorder_render_list(['user_id' => $user_id])} +``` + +#### 在控制器中引入 +在控制器的构造函数或方法中: + +```php + +{assign name="records" value=":jerryyan\recorder\helper\ViewHelper::getRecords(['user_id' => $user_id])" /} + + +{:jerryyan\recorder\helper\ViewHelper::renderList(['user_id' => $user_id], ['limit' => 10])} + + +{:jerryyan\recorder\helper\ViewHelper::renderTimeline(['data_type' => 'order', 'data_id' => $order_id])} + + +{:jerryyan\recorder\helper\ViewHelper::renderItem($record, ['show_extra' => true])} + + +{assign name="stats" value=":jerryyan\recorder\helper\ViewHelper::getStats()" /} +``` + +## 测试函数注册 + +运行测试脚本检查函数是否正确注册: + +```bash +php plugs/think-plugs-recorder/test_functions.php +``` + +## 可用的模板函数 + +注册成功后,以下函数可在模板中直接使用: + +### recorder_get_records() +获取操作记录数据: +```html +{assign name="records" value=":recorder_get_records(['user_id' => $user_id, 'limit' => 5])" /} + +{volist name="records" id="record"} +
{$record.operation_type} - {$record.operation_desc}
+{/volist} +``` + +### recorder_render_list() +渲染记录列表: +```html + +{:recorder_render_list(['user_id' => $user_id])} + + +{:recorder_render_list(['data_type' => 'order'], ['theme' => 'card', 'limit' => 10])} + + +{:recorder_render_list(['user_id' => $user_id], ['compact' => true, 'show_user' => false])} +``` + +### recorder_render_timeline() +渲染时间线: +```html +{:recorder_render_timeline(['data_type' => 'project', 'data_id' => $project_id])} +``` + +### recorder_render_item() +渲染单条记录: +```html +{:recorder_render_item($record, ['show_extra' => true])} +``` + +### recorder_get_stats() +获取统计数据: +```html +{assign name="stats" value=":recorder_get_stats(['start_time' => '2024-01-01'])" /} + +

总操作次数: {$stats.total_count}

+``` + +## 常见问题 + +### Q: 提示 "Call to undefined function recorder_get_records()" +A: 函数未正确注册,请按方案1或方案2解决。 + +### Q: 函数存在但调用时报错 +A: 可能是数据库连接问题或权限问题,检查数据库配置和表是否存在。 + +### Q: 在某些页面可以使用,某些页面不能使用 +A: 可能是自动加载时机问题,建议使用方案2在应用启动时全局引入。 + +### Q: 开发环境正常,生产环境报错 +A: 生产环境需要重新执行 `composer dump-autoload --optimize`。 + +## 最佳实践 + +1. **推荐使用方案1**: 通过Composer自动加载是最标准的方式 +2. **在应用启动时引入**: 确保所有页面都能使用函数 +3. **备用方案**: 准备类方法调用作为备用方案 +4. **错误处理**: 在模板中使用函数前检查函数是否存在 + +```html +{if function_exists('recorder_render_list')} + {:recorder_render_list(['user_id' => $user_id])} +{else} + {:jerryyan\recorder\helper\ViewHelper::renderList(['user_id' => $user_id])} +{/if} +``` \ No newline at end of file diff --git a/composer.json b/composer.json index 3756d98..79304bc 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,10 @@ "autoload": { "psr-4": { "jerryyan\\recorder\\": "src" - } + }, + "files": [ + "src/functions.php" + ] }, "require": { diff --git a/src/Service.php b/src/Service.php index b1c00c7..060486e 100644 --- a/src/Service.php +++ b/src/Service.php @@ -3,13 +3,331 @@ namespace jerryyan\recorder; use think\admin\Plugin; +use jerryyan\recorder\helper\ViewHelper; +/** + * 操作记录插件服务类 + * Class Service + * @package jerryyan\recorder + */ class Service extends Plugin { + /** + * 插件编码 + * @var string + */ protected $appCode = 'recorder'; + + /** + * 插件名称 + * @var string + */ protected $appName = '操作记录'; + + /** + * 定义插件菜单 + * @return array + */ public static function menu(): array { - return []; + // 暂时不提供后台菜单,仅作为服务插件使用 + return [ + [ + 'name' => '操作记录', + 'subs' => [ + [ + 'name' => '记录查询', + 'icon' => 'layui-icon layui-icon-search', + 'node' => 'recorder/index/index' + ], + [ + 'name' => '记录统计', + 'icon' => 'layui-icon layui-icon-chart', + 'node' => 'recorder/stats/index' + ] + ] + ] + ]; + } + + /** + * 插件服务注册 + */ + public function register() + { + // 注册事件监听器 + $this->registerEventListeners(); + } + + /** + * 插件启动 + */ + public function boot(): void + { + // 插件启动时的初始化操作 + $this->initializePlugin(); + + // 注册模板函数 + $this->registerTemplateFunctions(); + } + + /** + * 注册模板函数 + */ + protected function registerTemplateFunctions() + { + try { + // 函数已经通过composer autoload的files自动加载 + // 这里只需要确认函数是否正确加载 + if (!function_exists('recorder_get_records')) { + // 如果通过autoload加载失败,手动加载 + require_once __DIR__ . '/functions.php'; + } + } catch (\Exception $e) { + trace("注册模板函数失败: " . $e->getMessage(), 'error'); + } + } + + /** + * 注册事件监听器 + */ + protected function registerEventListeners() + { + // 可以在这里注册模型事件监听器,自动记录CRUD操作 + // 例如:监听模型的增删改事件,自动记录操作日志 + + try { + // 注册全局模型事件监听 + \think\facade\Event::listen('think\\model\\concern\\ModelEvent', function($event, $model) { + $this->handleModelEvent($event, $model); + }); + } catch (\Exception $e) { + trace("注册事件监听器失败: " . $e->getMessage(), 'error'); + } + } + + /** + * 处理模型事件 + * @param string $event + * @param $model + */ + protected function handleModelEvent(string $event, $model) + { + // 根据配置决定是否自动记录模型操作 + $config = \jerryyan\recorder\service\RecorderService::getConfig('auto_record', []); + + if (!($config['enabled'] ?? false)) { + return; + } + + try { + $operationMap = [ + 'after_insert' => '创建', + 'after_update' => '更新', + 'after_delete' => '删除', + ]; + + if (isset($operationMap[$event])) { + $operationType = $operationMap[$event]; + + // 检查是否排除此操作类型 + $excludeOperations = $config['exclude_operations'] ?? []; + if (in_array($operationType, $excludeOperations)) { + return; + } + + $tableName = $model->getTable(); + $primaryKey = $model->getPk(); + $primaryValue = $model->$primaryKey ?? ''; + + \jerryyan\recorder\service\RecorderService::autoRecord([ + 'operation_type' => $operationType, + 'operation_desc' => "{$operationType}{$tableName}记录", + 'data_type' => $tableName, + 'data_id' => (string)$primaryValue, + ]); + } + } catch (\Exception $e) { + trace("自动记录模型操作失败: " . $e->getMessage(), 'error'); + } + } + + /** + * 初始化插件 + */ + protected function initializePlugin() + { + // 设置默认配置 + $this->setDefaultConfig(); + + // 检查数据表是否存在 + $this->checkDatabaseTables(); + } + + /** + * 设置默认配置 + */ + protected function setDefaultConfig() + { + $defaultConfig = [ + 'enabled' => true, + 'auto_record' => [ + 'enabled' => false, + 'exclude_operations' => ['读取'], + 'exclude_controllers' => [], + ], + 'retention_days' => 90, + 'sensitive_operations' => ['删除', '导出'], + 'view' => [ + 'default_limit' => 10, + 'date_format' => 'Y-m-d H:i:s', + 'theme' => 'default', + 'show_user' => true, + 'show_ip' => false, + 'compact_mode' => false, + ] + ]; + + // 合并用户配置 + $userConfig = config('recorder', []); + $finalConfig = array_merge($defaultConfig, $userConfig); + + // 更新配置 + config(['recorder' => $finalConfig]); + } + + /** + * 检查数据表是否存在 + */ + protected function checkDatabaseTables() + { + try { + $db = \think\facade\Db::connect(); + + // 检查操作记录表是否存在 + $tables = $db->query("SHOW TABLES LIKE 'jl_recorder_log'"); + + if (empty($tables)) { + trace("操作记录表不存在,请运行数据库迁移脚本:php think migrate:run", 'notice'); + } + } catch (\Exception $e) { + trace("检查数据表失败: " . $e->getMessage(), 'error'); + } + } + + /** + * 获取插件版本 + * @return string + */ + public function getVersion(): string + { + return 'v1.0.0'; + } + + /** + * 获取插件描述 + * @return string + */ + public function getDescription(): string + { + return '提供用户操作记录功能,支持手动记录和自动记录,包含完善的查询和视图展示功能'; + } + + /** + * 获取插件作者 + * @return string + */ + public function getAuthor(): string + { + return 'Jerry Yan'; + } + + /** + * 插件安装 + * @return bool + */ + public function install(): bool + { + try { + // 运行数据库迁移 + $this->runMigrations(); + + // 初始化配置 + $this->setDefaultConfig(); + + trace("操作记录插件安装成功", 'info'); + return true; + } catch (\Exception $e) { + trace("操作记录插件安装失败: " . $e->getMessage(), 'error'); + return false; + } + } + + /** + * 插件卸载 + * @return bool + */ + public function uninstall(): bool + { + try { + // 这里可以添加卸载时的清理操作 + // 注意:通常不建议自动删除数据表,避免数据丢失 + + trace("操作记录插件卸载成功", 'info'); + return true; + } catch (\Exception $e) { + trace("操作记录插件卸载失败: " . $e->getMessage(), 'error'); + return false; + } + } + + /** + * 运行数据库迁移 + */ + protected function runMigrations() + { + // 这里可以调用Phinx迁移命令 + // 或者直接执行SQL创建表结构 + try { + // 示例:直接执行建表SQL + $sql = $this->getCreateTableSql(); + \think\facade\Db::execute($sql); + } catch (\Exception $e) { + trace("执行数据库迁移失败: " . $e->getMessage(), 'error'); + throw $e; + } + } + + /** + * 获取建表SQL + * @return string + */ + protected function getCreateTableSql(): string + { + return " + CREATE TABLE IF NOT EXISTS `jl_recorder_log` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '记录ID', + `operation_type` varchar(50) NOT NULL DEFAULT '' COMMENT '操作类型', + `operation_desc` varchar(500) NOT NULL DEFAULT '' COMMENT '操作说明', + `user_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '操作用户ID', + `user_nickname` varchar(100) NOT NULL DEFAULT '' COMMENT '操作用户昵称', + `data_type` varchar(100) NOT NULL DEFAULT '' COMMENT '操作数据类型', + `data_id` varchar(100) NOT NULL DEFAULT '' COMMENT '操作数据ID', + `related_type` varchar(100) NOT NULL DEFAULT '' COMMENT '关联数据类型', + `related_id` varchar(100) NOT NULL DEFAULT '' COMMENT '关联数据ID', + `request_method` varchar(10) NOT NULL DEFAULT '' COMMENT '请求方法', + `request_url` varchar(500) NOT NULL DEFAULT '' COMMENT '请求URL', + `request_ip` varchar(50) NOT NULL DEFAULT '' COMMENT '请求IP', + `user_agent` varchar(500) NOT NULL DEFAULT '' COMMENT '用户代理', + `extra_data` text COMMENT '额外数据(JSON格式)', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_operation_type` (`operation_type`), + KEY `idx_data_type_id` (`data_type`, `data_id`), + KEY `idx_created_at` (`created_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='操作记录表'; + "; } } \ No newline at end of file diff --git a/src/functions.php b/src/functions.php new file mode 100644 index 0000000..4e93248 --- /dev/null +++ b/src/functions.php @@ -0,0 +1,133 @@ + 10, + 'show_user' => true, + 'show_ip' => false, + 'show_extra' => false, + 'date_format' => 'Y-m-d H:i:s', + 'theme' => 'default', + 'compact' => false, + 'css_class' => '', + 'template' => '', + ]; + + /** + * 获取操作记录数据 + * @param array $conditions 查询条件 + * @return array + */ + public static function getRecords(array $conditions = []): array + { + try { + $result = RecorderService::query($conditions); + return $result['data'] ?? []; + } catch (\Exception $e) { + trace("获取操作记录失败: " . $e->getMessage(), 'error'); + return []; + } + } + + /** + * 渲染记录列表HTML + * @param array $conditions 查询条件 + * @param array $options 显示选项 + * @return string + */ + public static function renderList(array $conditions = [], array $options = []): string + { + $options = array_merge(static::$defaultOptions, RecorderService::getConfig('view', []), $options); + + // 获取记录数据 + $records = static::getRecords($conditions); + + if (empty($records)) { + return static::renderEmpty($options); + } + + // 处理数据格式化 + $records = static::formatRecords($records, $options); + + // 选择渲染模板 + $template = static::getTemplate('list', $options); + + return static::render($template, [ + 'records' => $records, + 'options' => $options, + ]); + } + + /** + * 渲染时间线HTML + * @param array $conditions 查询条件 + * @param array $options 显示选项 + * @return string + */ + public static function renderTimeline(array $conditions = [], array $options = []): string + { + $options = array_merge(static::$defaultOptions, RecorderService::getConfig('view', []), $options); + $options['theme'] = 'timeline'; // 强制使用时间线主题 + + // 获取记录数据 + $records = static::getRecords($conditions); + + if (empty($records)) { + return static::renderEmpty($options); + } + + // 处理数据格式化 + $records = static::formatRecords($records, $options); + + // 选择渲染模板 + $template = static::getTemplate('timeline', $options); + + return static::render($template, [ + 'records' => $records, + 'options' => $options, + ]); + } + + /** + * 渲染单条记录HTML + * @param array $record 记录数据 + * @param array $options 显示选项 + * @return string + */ + public static function renderItem(array $record, array $options = []): string + { + $options = array_merge(static::$defaultOptions, RecorderService::getConfig('view', []), $options); + + if (empty($record)) { + return ''; + } + + // 处理数据格式化 + $record = static::formatRecord($record, $options); + + // 选择渲染模板 + $template = static::getTemplate('item', $options); + + return static::render($template, [ + 'record' => $record, + 'options' => $options, + ]); + } + + /** + * 获取操作统计数据 + * @param array $conditions 查询条件 + * @return array + */ + public static function getStats(array $conditions = []): array + { + try { + return RecorderService::getStats($conditions); + } catch (\Exception $e) { + trace("获取操作统计失败: " . $e->getMessage(), 'error'); + return []; + } + } + + /** + * 获取数据的读取用户列表 + * @param string $dataType 数据类型 + * @param string $dataId 数据ID + * @param array $conditions 额外条件 + * @return array + */ + public static function getDataReaders(string $dataType, string $dataId, array $conditions = []): array + { + try { + return RecorderService::getDataReaders($dataType, $dataId, $conditions); + } catch (\Exception $e) { + trace("获取数据读取用户失败: " . $e->getMessage(), 'error'); + return []; + } + } + + /** + * 获取数据的操作用户列表(排除读取) + * @param string $dataType 数据类型 + * @param string $dataId 数据ID + * @param array $conditions 额外条件 + * @return array + */ + public static function getDataOperators(string $dataType, string $dataId, array $conditions = []): array + { + try { + return RecorderService::getDataOperators($dataType, $dataId, $conditions); + } catch (\Exception $e) { + trace("获取数据操作用户失败: " . $e->getMessage(), 'error'); + return []; + } + } + + /** + * 渲染数据读取用户列表 + * @param string $dataType 数据类型 + * @param string $dataId 数据ID + * @param array $conditions 查询条件 + * @param array $options 显示选项 + * @return string + */ + public static function renderDataReaders(string $dataType, string $dataId, array $conditions = [], array $options = []): string + { + $options = array_merge(static::$defaultOptions, RecorderService::getConfig('view', []), $options); + + // 获取读取用户数据 + $readers = static::getDataReaders($dataType, $dataId, $conditions); + + if (empty($readers)) { + return static::renderEmpty($options, '暂无用户读取过此数据'); + } + + // 选择渲染模板 + $template = static::getTemplate('readers', $options); + + return static::render($template, [ + 'readers' => $readers, + 'data_type' => $dataType, + 'data_id' => $dataId, + 'options' => $options, + ]); + } + + /** + * 渲染数据操作用户列表 + * @param string $dataType 数据类型 + * @param string $dataId 数据ID + * @param array $conditions 查询条件 + * @param array $options 显示选项 + * @return string + */ + public static function renderDataOperators(string $dataType, string $dataId, array $conditions = [], array $options = []): string + { + $options = array_merge(static::$defaultOptions, RecorderService::getConfig('view', []), $options); + + // 获取操作用户数据 + $operators = static::getDataOperators($dataType, $dataId, $conditions); + + if (empty($operators)) { + return static::renderEmpty($options, '暂无用户操作过此数据'); + } + + // 选择渲染模板 + $template = static::getTemplate('operators', $options); + + return static::render($template, [ + 'operators' => $operators, + 'data_type' => $dataType, + 'data_id' => $dataId, + 'options' => $options, + ]); + } + + /** + * 注册模板函数 + */ + public static function registerTemplateFunctions(): void + { + // 注册获取记录数据函数 + if (!function_exists('recorder_get_records')) { + function recorder_get_records(array $conditions = []): array { + return ViewHelper::getRecords($conditions); + } + } + + // 注册渲染列表函数 + if (!function_exists('recorder_render_list')) { + function recorder_render_list(array $conditions = [], array $options = []): string { + return ViewHelper::renderList($conditions, $options); + } + } + + // 注册渲染时间线函数 + if (!function_exists('recorder_render_timeline')) { + function recorder_render_timeline(array $conditions = [], array $options = []): string { + return ViewHelper::renderTimeline($conditions, $options); + } + } + + // 注册渲染单条记录函数 + if (!function_exists('recorder_render_item')) { + function recorder_render_item(array $record, array $options = []): string { + return ViewHelper::renderItem($record, $options); + } + } + + // 注册获取统计数据函数 + if (!function_exists('recorder_get_stats')) { + function recorder_get_stats(array $conditions = []): array { + return ViewHelper::getStats($conditions); + } + } + } + + /** + * 格式化记录数组 + * @param array $records + * @param array $options + * @return array + */ + protected static function formatRecords(array $records, array $options): array + { + return array_map(function($record) use ($options) { + return static::formatRecord($record, $options); + }, $records); + } + + /** + * 格式化单条记录 + * @param array $record + * @param array $options + * @return array + */ + protected static function formatRecord(array $record, array $options): array + { + // 格式化时间 + if (!empty($record['created_at'])) { + $record['created_at_formatted'] = date($options['date_format'], strtotime($record['created_at'])); + $record['created_at_relative'] = static::getRelativeTime($record['created_at']); + } + + // 格式化操作类型标签 + $record['operation_type_label'] = static::getOperationTypeLabel($record['operation_type'] ?? ''); + + // 格式化用户信息 + $record['user_info'] = static::formatUserInfo($record, $options); + + // 格式化数据信息 + $record['data_info'] = static::formatDataInfo($record); + + // 格式化关联信息 + $record['related_info'] = static::formatRelatedInfo($record); + + return $record; + } + + /** + * 获取操作类型标签 + * @param string $operationType + * @return array + */ + protected static function getOperationTypeLabel(string $operationType): array + { + $typeLabels = [ + '创建' => ['class' => 'success', 'text' => '创建', 'icon' => 'plus'], + '读取' => ['class' => 'info', 'text' => '读取', 'icon' => 'eye'], + '更新' => ['class' => 'warning', 'text' => '更新', 'icon' => 'edit'], + '删除' => ['class' => 'danger', 'text' => '删除', 'icon' => 'trash'], + '导出' => ['class' => 'primary', 'text' => '导出', 'icon' => 'download'], + '登录' => ['class' => 'success', 'text' => '登录', 'icon' => 'user'], + '登出' => ['class' => 'default', 'text' => '登出', 'icon' => 'user-times'], + '审核' => ['class' => 'warning', 'text' => '审核', 'icon' => 'check'], + '发布' => ['class' => 'primary', 'text' => '发布', 'icon' => 'share'], + ]; + + return $typeLabels[$operationType] ?? ['class' => 'default', 'text' => $operationType, 'icon' => 'circle']; + } + + /** + * 格式化用户信息 + * @param array $record + * @param array $options + * @return string + */ + protected static function formatUserInfo(array $record, array $options): string + { + if (!$options['show_user']) { + return ''; + } + + $nickname = $record['user_nickname'] ?? ''; + $userId = $record['user_id'] ?? 0; + + if (!empty($nickname)) { + return $nickname . " (ID:{$userId})"; + } + return "用户ID:{$userId}"; + } + + /** + * 格式化数据信息 + * @param array $record + * @return string + */ + protected static function formatDataInfo(array $record): string + { + $dataType = $record['data_type'] ?? ''; + $dataId = $record['data_id'] ?? ''; + + if (!empty($dataType) && !empty($dataId)) { + return "{$dataType}:{$dataId}"; + } elseif (!empty($dataType)) { + return $dataType; + } + return ''; + } + + /** + * 格式化关联信息 + * @param array $record + * @return string + */ + protected static function formatRelatedInfo(array $record): string + { + $relatedType = $record['related_type'] ?? ''; + $relatedId = $record['related_id'] ?? ''; + + if (!empty($relatedType) && !empty($relatedId)) { + return "{$relatedType}:{$relatedId}"; + } elseif (!empty($relatedType)) { + return $relatedType; + } + return ''; + } + + /** + * 获取相对时间 + * @param string $datetime + * @return string + */ + protected static function getRelativeTime(string $datetime): string + { + $time = strtotime($datetime); + $now = time(); + $diff = $now - $time; + + if ($diff < 60) { + return '刚刚'; + } elseif ($diff < 3600) { + return intval($diff / 60) . '分钟前'; + } elseif ($diff < 86400) { + return intval($diff / 3600) . '小时前'; + } elseif ($diff < 604800) { + return intval($diff / 86400) . '天前'; + } else { + return date('Y-m-d', $time); + } + } + + /** + * 获取模板路径 + * @param string $type 模板类型 + * @param array $options 选项 + * @return string + */ + protected static function getTemplate(string $type, array $options): string + { + // 如果指定了自定义模板,优先使用 + if (!empty($options['template'])) { + return $options['template']; + } + + // 根据主题和类型选择模板 + $theme = $options['theme'] ?? 'default'; + $templateMap = [ + 'list' => "recorder/components/record_list_{$theme}", + 'timeline' => "recorder/components/record_timeline", + 'item' => "recorder/components/record_item_{$theme}", + 'readers' => "recorder/components/data_readers", + 'operators' => "recorder/components/data_operators", + ]; + + return $templateMap[$type] ?? "recorder/components/record_{$type}"; + } + + /** + * 渲染模板 + * @param string $template 模板路径 + * @param array $vars 模板变量 + * @return string + */ + protected static function render(string $template, array $vars): string + { + try { + // 尝试使用ThinkPHP的视图渲染 + if (class_exists('\\think\\facade\\View')) { + return View::fetch($template, $vars); + } + } catch (\Exception $e) { + // 视图渲染失败,使用内置HTML渲染 + trace("视图渲染失败,使用内置渲染: " . $e->getMessage(), 'notice'); + } + + // 内置HTML渲染 + return static::renderBuiltinHtml($template, $vars); + } + + /** + * 内置HTML渲染 + * @param string $template + * @param array $vars + * @return string + */ + protected static function renderBuiltinHtml(string $template, array $vars): string + { + $records = $vars['records'] ?? []; + $readers = $vars['readers'] ?? []; + $operators = $vars['operators'] ?? []; + $options = $vars['options'] ?? []; + $record = $vars['record'] ?? null; + + if (strpos($template, 'readers') !== false) { + return static::renderReadersHtml($readers, $options); + } elseif (strpos($template, 'operators') !== false) { + return static::renderOperatorsHtml($operators, $options); + } elseif (strpos($template, 'timeline') !== false) { + return static::renderTimelineHtml($records, $options); + } elseif (strpos($template, 'item') !== false && $record) { + return static::renderItemHtml($record, $options); + } else { + return static::renderListHtml($records, $options); + } + } + + /** + * 渲染列表HTML + * @param array $records + * @param array $options + * @return string + */ + protected static function renderListHtml(array $records, array $options): string + { + $cssClass = 'recorder-list ' . ($options['css_class'] ?? ''); + $compactClass = $options['compact'] ? ' recorder-list--compact' : ''; + + $html = "
"; + + foreach ($records as $record) { + $html .= static::renderItemHtml($record, $options); + } + + $html .= '
'; + + return $html; + } + + /** + * 渲染时间线HTML + * @param array $records + * @param array $options + * @return string + */ + protected static function renderTimelineHtml(array $records, array $options): string + { + $cssClass = 'recorder-timeline ' . ($options['css_class'] ?? ''); + + $html = "
"; + + foreach ($records as $record) { + $typeLabel = $record['operation_type_label']; + $html .= '
'; + $html .= "
"; + $html .= '
'; + $html .= "
"; + $html .= "{$typeLabel['text']}"; + $html .= "{$record['created_at_relative']}"; + $html .= "
"; + $html .= "
{$record['operation_desc']}
"; + if ($options['show_user'] && !empty($record['user_info'])) { + $html .= "
{$record['user_info']}
"; + } + $html .= '
'; + $html .= '
'; + } + + $html .= '
'; + + return $html; + } + + /** + * 渲染单项HTML + * @param array $record + * @param array $options + * @return string + */ + protected static function renderItemHtml(array $record, array $options): string + { + $typeLabel = $record['operation_type_label']; + $cssClass = 'recorder-item'; + + if ($options['theme'] === 'card') { + $cssClass .= ' recorder-item--card'; + } + + $html = "
"; + $html .= "
"; + $html .= "{$typeLabel['text']}"; + $html .= "{$record['created_at_formatted']}"; + $html .= "
"; + $html .= "
"; + $html .= "
{$record['operation_desc']}
"; + + if ($options['show_user'] && !empty($record['user_info'])) { + $html .= "
操作人:{$record['user_info']}
"; + } + + if (!empty($record['data_info'])) { + $html .= "
数据:{$record['data_info']}
"; + } + + if ($options['show_ip'] && !empty($record['request_ip'])) { + $html .= "
IP:{$record['request_ip']}
"; + } + + $html .= "
"; + $html .= "
"; + + return $html; + } + + /** + * 渲染读取用户HTML + * @param array $readers + * @param array $options + * @return string + */ + protected static function renderReadersHtml(array $readers, array $options): string + { + $cssClass = 'recorder-readers ' . ($options['css_class'] ?? ''); + + $html = "
"; + + if (empty($readers)) { + $html .= '
暂无用户读取过此数据
'; + } else { + $html .= '
'; + $html .= '
读取用户 (' . count($readers) . ')
'; + $html .= '
'; + + $html .= '
'; + foreach ($readers as $reader) { + $html .= '
'; + $html .= '
'; + $html .= ''; + $html .= '
'; + $html .= '
'; + $html .= '
' . $reader['user_nickname'] . '
'; + $html .= '
'; + $html .= '读取 ' . $reader['read_count'] . ' 次'; + if ($reader['first_read_at'] !== $reader['last_read_at']) { + $html .= '最后: ' . $reader['last_read_at_relative'] . ''; + } else { + $html .= '' . $reader['first_read_at_relative'] . ''; + } + $html .= '
'; + $html .= '
'; + $html .= '
'; + } + $html .= '
'; + } + + $html .= '
'; + + // 添加CSS样式 + $html .= ''; + + return $html; + } + + /** + * 渲染操作用户HTML + * @param array $operators + * @param array $options + * @return string + */ + protected static function renderOperatorsHtml(array $operators, array $options): string + { + $cssClass = 'recorder-operators ' . ($options['css_class'] ?? ''); + + $html = "
"; + + if (empty($operators)) { + $html .= '
暂无用户操作过此数据
'; + } else { + $html .= '
'; + $html .= '
操作用户 (' . count($operators) . ')
'; + $html .= '
'; + + $html .= '
'; + foreach ($operators as $operator) { + $html .= '
'; + $html .= '
'; + $html .= ''; + $html .= '
'; + $html .= '
'; + $html .= '
' . $operator['user_nickname'] . '
'; + $html .= '
' . $operator['operation_summary'] . '
'; + $html .= '
'; + $html .= '操作 ' . $operator['total_operations'] . ' 次'; + $html .= '最后: ' . $operator['last_operation_at_relative'] . ''; + $html .= '
'; + $html .= '
'; + $html .= '
'; + } + $html .= '
'; + } + + $html .= '
'; + + // 添加CSS样式 + $html .= ''; + + return $html; + } + + /** + * 渲染空状态HTML + * @param array $options + * @param string $message + * @return string + */ + protected static function renderEmpty(array $options, string $message = '暂无操作记录'): string + { + return "
{$message}
"; + } +} \ No newline at end of file diff --git a/src/middleware/RecorderMiddleware.php b/src/middleware/RecorderMiddleware.php new file mode 100644 index 0000000..6bdefab --- /dev/null +++ b/src/middleware/RecorderMiddleware.php @@ -0,0 +1,695 @@ +recordOperation($request, $response); + + return $response; + } + + /** + * 记录操作 + * @param Request $request + * @param Response $response + */ + protected function recordOperation(Request $request, Response $response): void + { + try { + // 检查是否启用自动记录 + $config = RecorderService::getConfig('auto_record', []); + if (!($config['enabled'] ?? false)) { + return; + } + + // 检查是否需要排除此请求 + if ($this->shouldExclude($request, $config)) { + return; + } + + // 只记录成功的请求 + $statusCode = $response->getCode(); + if ($statusCode >= 400) { + return; + } + + // 获取操作信息 + $operationInfo = $this->getOperationInfo($request, $response); + + if (!empty($operationInfo)) { + RecorderService::autoRecord($operationInfo); + } + + } catch (\Exception $e) { + // 中间件记录失败不应影响主要业务 + trace("中间件自动记录失败: " . $e->getMessage(), 'error'); + } + } + + /** + * 检查是否应该排除此请求 + * @param Request $request + * @param array $config + * @return bool + */ + protected function shouldExclude(Request $request, array $config): bool + { + $method = $request->method(); + $route = $request->pathinfo(); + $controller = $request->controller(); + $action = $request->action(); + + // 排除特定的请求方法 + if (in_array($method, $this->excludeMethods)) { + return true; + } + + // 排除特定的路由 + if (in_array($route, $this->excludeRoutes)) { + return true; + } + + // 排除配置中指定的控制器 + $excludeControllers = $config['exclude_controllers'] ?? []; + $currentController = strtolower($controller); + foreach ($excludeControllers as $excludeController) { + if (strpos($currentController, strtolower($excludeController)) !== false) { + return true; + } + } + + // 排除读取操作(如果配置了排除读取) + $excludeOperations = $config['exclude_operations'] ?? []; + if (in_array('读取', $excludeOperations) && $method === 'GET') { + return true; + } + + // 排除静态资源请求 + if ($this->isStaticResource($request)) { + return true; + } + + return false; + } + + /** + * 获取操作信息 + * @param Request $request + * @param Response $response + * @return array + */ + protected function getOperationInfo(Request $request, Response $response): array + { + $method = $request->method(); + $controller = $request->controller(); + $action = $request->action(); + $route = $request->pathinfo(); + + // 根据请求方法和路由确定操作类型 + $operationType = $this->determineOperationType($method, $route, $request); + + if (empty($operationType)) { + return []; + } + + // 生成操作描述 + $operationDesc = $this->generateOperationDescription($operationType, $controller, $action, $request); + + // 尝试获取操作的数据信息 + $dataInfo = $this->extractDataInfo($request, $response); + + return [ + 'operation_type' => $operationType, + 'operation_desc' => $operationDesc, + 'data_type' => $dataInfo['data_type'] ?? '', + 'data_id' => $dataInfo['data_id'] ?? '', + 'related_type' => $dataInfo['related_type'] ?? '', + 'related_id' => $dataInfo['related_id'] ?? '', + 'extra_data' => $this->getExtraData($request, $response), + ]; + } + + /** + * 确定操作类型 + * @param string $method + * @param string $route + * @param Request $request + * @return string + */ + protected function determineOperationType(string $method, string $route, Request $request): string + { + // 优先从控制器方法注释获取操作类型 + $operationTypeFromComment = $this->getOperationTypeFromComment($request); + if (!empty($operationTypeFromComment)) { + return $operationTypeFromComment; + } + + // 根据HTTP方法映射操作类型 + $methodMap = [ + 'POST' => '创建', + 'PUT' => '更新', + 'PATCH' => '更新', + 'DELETE' => '删除', + 'GET' => '读取', + ]; + + $baseType = $methodMap[$method] ?? ''; + + // 根据路由特征进一步判断 + if (strpos($route, 'export') !== false) { + return '导出'; + } + + if (strpos($route, 'import') !== false) { + return '导入'; + } + + if (strpos($route, 'upload') !== false) { + return '上传'; + } + + if (strpos($route, 'download') !== false) { + return '下载'; + } + + if (strpos($route, 'login') !== false) { + return '登录'; + } + + if (strpos($route, 'logout') !== false) { + return '登出'; + } + + if (strpos($route, 'audit') !== false || strpos($route, 'approve') !== false) { + return '审核'; + } + + if (strpos($route, 'publish') !== false) { + return '发布'; + } + + // 检查POST请求的特定动作 + if ($method === 'POST') { + $action = $request->param('action', ''); + if (!empty($action)) { + $actionMap = [ + 'save' => '保存', + 'update' => '更新', + 'delete' => '删除', + 'remove' => '删除', + 'enable' => '启用', + 'disable' => '禁用', + 'sort' => '排序', + 'move' => '移动', + ]; + + if (isset($actionMap[$action])) { + return $actionMap[$action]; + } + } + } + + return $baseType; + } + + /** + * 从控制器方法注释中获取操作类型 + * @param Request $request + * @return string + */ + protected function getOperationTypeFromComment(Request $request): string + { + try { + $controller = $request->controller(); + $action = $request->action(); + + if (empty($controller) || empty($action)) { + return ''; + } + + // 构造控制器类名 + $controllerClass = $this->buildControllerClassName($controller); + + if (!class_exists($controllerClass)) { + return ''; + } + + // 获取方法的反射对象 + $reflection = new \ReflectionClass($controllerClass); + + if (!$reflection->hasMethod($action)) { + return ''; + } + + $method = $reflection->getMethod($action); + $docComment = $method->getDocComment(); + + if (empty($docComment)) { + return ''; + } + + // 解析注释中的操作类型 + return $this->parseOperationTypeFromDocComment($docComment); + + } catch (\Exception $e) { + trace("获取方法注释失败: " . $e->getMessage(), 'error'); + return ''; + } + } + + /** + * 构建控制器类名 + * @param string $controller + * @return string + */ + protected function buildControllerClassName(string $controller): string + { + // 处理不同的控制器命名方式 + // 例如: admin/user -> app\admin\controller\UserController + + $parts = explode('/', $controller); + + if (count($parts) >= 2) { + // 多层控制器: admin/user + $app = $parts[0]; + $controllerName = ucfirst($parts[1]); + return "app\\{$app}\\controller\\{$controllerName}Controller"; + } else { + // 单层控制器: User + $controllerName = ucfirst($controller); + return "app\\controller\\{$controllerName}Controller"; + } + } + + /** + * 从文档注释中解析操作类型 + * @param string $docComment + * @return string + */ + protected function parseOperationTypeFromDocComment(string $docComment): string + { + // 支持多种注释格式 + $patterns = [ + // @operation 创建用户 + '/@operation\s+(.+?)(?:\n|\r\n|\r|$)/i', + // @action 创建用户 + '/@action\s+(.+?)(?:\n|\r\n|\r|$)/i', + // @title 创建用户 + '/@title\s+(.+?)(?:\n|\r\n|\r|$)/i', + // @recorder 创建 + '/@recorder\s+(.+?)(?:\n|\r\n|\r|$)/i', + // @record_type 创建 + '/@record_type\s+(.+?)(?:\n|\r\n|\r|$)/i', + // @操作类型 创建 + '/@操作类型\s+(.+?)(?:\n|\r\n|\r|$)/i', + ]; + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $docComment, $matches)) { + $operationType = trim($matches[1]); + // 去除可能的描述,只保留操作类型 + $operationType = $this->extractOperationType($operationType); + if (!empty($operationType)) { + return $operationType; + } + } + } + + // 如果没有找到特定的操作类型注解,尝试从第一行注释中提取 + if (preg_match('/\/\*\*\s*\n\s*\*\s*(.+?)(?:\n|\r\n|\r)/i', $docComment, $matches)) { + $firstLine = trim($matches[1]); + $operationType = $this->extractOperationType($firstLine); + if (!empty($operationType)) { + return $operationType; + } + } + + return ''; + } + + /** + * 从文本中提取操作类型 + * @param string $text + * @return string + */ + protected function extractOperationType(string $text): string + { + // 定义常见的操作类型关键词 + $operationTypes = [ + '创建', '新增', '添加', '新建', + '更新', '修改', '编辑', '保存', + '删除', '移除', '清除', + '查看', '读取', '获取', '查询', '列表', + '导出', '导入', '上传', '下载', + '登录', '登出', '注销', + '审核', '审批', '通过', '拒绝', + '发布', '取消发布', + '启用', '禁用', '开启', '关闭', + '排序', '移动', '复制', + '搜索', '检索', '筛选', + '统计', '分析', '报表' + ]; + + // 去除常见的前缀词 + $text = preg_replace('/^(执行|进行|处理|操作)\s*/', '', $text); + + // 查找匹配的操作类型 + foreach ($operationTypes as $type) { + if (strpos($text, $type) === 0) { + return $type; + } + } + + // 如果文本很短且是中文,可能就是操作类型 + if (mb_strlen($text) <= 4 && preg_match('/^[\x{4e00}-\x{9fa5}]+$/u', $text)) { + return $text; + } + + return ''; + } + + /** + * 生成操作描述 + * @param string $operationType + * @param string $controller + * @param string $action + * @param Request $request + * @return string + */ + protected function generateOperationDescription(string $operationType, string $controller, string $action, Request $request): string + { + // 优先从控制器方法注释获取完整的操作描述 + $descriptionFromComment = $this->getOperationDescriptionFromComment($request); + if (!empty($descriptionFromComment)) { + return $descriptionFromComment; + } + + // 控制器名称映射 + $controllerNames = [ + 'user' => '用户', + 'admin' => '管理员', + 'role' => '角色', + 'menu' => '菜单', + 'config' => '配置', + 'log' => '日志', + 'file' => '文件', + 'upload' => '上传', + 'system' => '系统', + ]; + + $controllerName = ''; + foreach ($controllerNames as $key => $name) { + if (stripos($controller, $key) !== false) { + $controllerName = $name; + break; + } + } + + // 如果没有匹配到控制器名称,使用原始控制器名 + if (empty($controllerName)) { + $controllerName = $controller; + } + + // 动作名称映射 + $actionNames = [ + 'index' => '列表', + 'add' => '添加页面', + 'edit' => '编辑页面', + 'save' => '保存', + 'delete' => '删除', + 'remove' => '移除', + 'view' => '查看', + 'detail' => '详情', + ]; + + $actionName = $actionNames[$action] ?? $action; + + // 生成描述 + if ($operationType === '读取' && $action === 'index') { + return "查看{$controllerName}列表"; + } elseif ($operationType === '读取') { + return "查看{$controllerName}{$actionName}"; + } else { + return "{$operationType}{$controllerName}"; + } + } + + /** + * 从控制器方法注释中获取操作描述 + * @param Request $request + * @return string + */ + protected function getOperationDescriptionFromComment(Request $request): string + { + try { + $controller = $request->controller(); + $action = $request->action(); + + if (empty($controller) || empty($action)) { + return ''; + } + + // 构造控制器类名 + $controllerClass = $this->buildControllerClassName($controller); + + if (!class_exists($controllerClass)) { + return ''; + } + + // 获取方法的反射对象 + $reflection = new \ReflectionClass($controllerClass); + + if (!$reflection->hasMethod($action)) { + return ''; + } + + $method = $reflection->getMethod($action); + $docComment = $method->getDocComment(); + + if (empty($docComment)) { + return ''; + } + + // 解析注释中的操作描述 + return $this->parseOperationDescriptionFromDocComment($docComment); + + } catch (\Exception $e) { + trace("获取方法描述失败: " . $e->getMessage(), 'error'); + return ''; + } + } + + /** + * 从文档注释中解析操作描述 + * @param string $docComment + * @return string + */ + protected function parseOperationDescriptionFromDocComment(string $docComment): string + { + // 支持多种注释格式获取操作描述 + $patterns = [ + // @operation 创建用户 + '/@operation\s+(.+?)(?:\n|\r\n|\r|$)/i', + // @action 创建用户 + '/@action\s+(.+?)(?:\n|\r\n|\r|$)/i', + // @title 创建用户 + '/@title\s+(.+?)(?:\n|\r\n|\r|$)/i', + // @recorder 创建用户 + '/@recorder\s+(.+?)(?:\n|\r\n|\r|$)/i', + // @desc 创建用户 + '/@desc\s+(.+?)(?:\n|\r\n|\r|$)/i', + // @description 创建用户 + '/@description\s+(.+?)(?:\n|\r\n|\r|$)/i', + // @操作描述 创建用户 + '/@操作描述\s+(.+?)(?:\n|\r\n|\r|$)/i', + ]; + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $docComment, $matches)) { + $description = trim($matches[1]); + if (!empty($description) && mb_strlen($description) <= 100) { + return $description; + } + } + } + + // 如果没有找到特定的注解,尝试从第一行注释中获取描述 + if (preg_match('/\/\*\*\s*\n\s*\*\s*(.+?)(?:\n|\r\n|\r)/i', $docComment, $matches)) { + $firstLine = trim($matches[1]); + // 去除常见的无意义词汇 + $firstLine = preg_replace('/^(方法|函数|接口|API|处理|执行)\s*[::]?\s*/', '', $firstLine); + if (!empty($firstLine) && mb_strlen($firstLine) <= 100) { + return $firstLine; + } + } + + return ''; + } + + /** + * 提取数据信息 + * @param Request $request + * @param Response $response + * @return array + */ + protected function extractDataInfo(Request $request, Response $response): array + { + $dataInfo = [ + 'data_type' => '', + 'data_id' => '', + 'related_type' => '', + 'related_id' => '', + ]; + + // 从控制器推断数据类型 + $controller = $request->controller(); + $dataInfo['data_type'] = strtolower(str_replace('Controller', '', basename($controller))); + + // 尝试从请求参数获取ID + $id = $request->param('id', ''); + if (empty($id)) { + $id = $request->param('ids', ''); + } + + if (!empty($id)) { + $dataInfo['data_id'] = is_array($id) ? implode(',', $id) : (string)$id; + } + + // 尝试从响应中获取数据ID(适用于创建操作) + if (empty($dataInfo['data_id']) && $request->method() === 'POST') { + $responseData = $this->parseResponseData($response); + if (isset($responseData['id'])) { + $dataInfo['data_id'] = (string)$responseData['id']; + } elseif (isset($responseData['data']['id'])) { + $dataInfo['data_id'] = (string)$responseData['data']['id']; + } + } + + // 尝试获取关联数据信息 + $parentId = $request->param('parent_id', $request->param('pid', '')); + if (!empty($parentId)) { + $dataInfo['related_type'] = $dataInfo['data_type']; + $dataInfo['related_id'] = (string)$parentId; + } + + return $dataInfo; + } + + /** + * 获取额外数据 + * @param Request $request + * @param Response $response + * @return array + */ + protected function getExtraData(Request $request, Response $response): array + { + $extra = []; + + // 记录请求参数(排除敏感信息) + $params = $request->param(); + $sensitiveKeys = ['password', 'pwd', 'token', 'key', 'secret']; + + foreach ($params as $key => $value) { + $lowerKey = strtolower($key); + $isSensitive = false; + + foreach ($sensitiveKeys as $sensitiveKey) { + if (strpos($lowerKey, $sensitiveKey) !== false) { + $isSensitive = true; + break; + } + } + + if (!$isSensitive) { + // 限制参数值长度,避免存储过大的数据 + if (is_string($value) && strlen($value) > 200) { + $value = substr($value, 0, 200) . '...'; + } + $extra['params'][$key] = $value; + } + } + + // 记录响应状态码 + $extra['response_code'] = $response->getCode(); + + return $extra; + } + + /** + * 解析响应数据 + * @param Response $response + * @return array + */ + protected function parseResponseData(Response $response): array + { + try { + $content = $response->getContent(); + if (is_string($content)) { + $data = json_decode($content, true); + return is_array($data) ? $data : []; + } + } catch (\Exception $e) { + // 解析失败,返回空数组 + } + + return []; + } + + /** + * 检查是否为静态资源请求 + * @param Request $request + * @return bool + */ + protected function isStaticResource(Request $request): bool + { + $uri = $request->pathinfo(); + $staticExtensions = ['css', 'js', 'jpg', 'jpeg', 'png', 'gif', 'ico', 'svg', 'woff', 'woff2', 'ttf', 'eot']; + + $extension = strtolower(pathinfo($uri, PATHINFO_EXTENSION)); + + return in_array($extension, $staticExtensions) || strpos($uri, '/static/') === 0; + } +} \ No newline at end of file diff --git a/src/model/RecorderLog.php b/src/model/RecorderLog.php new file mode 100644 index 0000000..030933e --- /dev/null +++ b/src/model/RecorderLog.php @@ -0,0 +1,263 @@ + 'integer', + 'user_id' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + /** + * JSON字段 + * @var array + */ + protected $json = ['extra_data']; + + /** + * 只读字段 + * @var array + */ + protected $readonly = ['id', 'created_at']; + + /** + * 字段验证规则 + * @return array + */ + protected function getRules(): array + { + return [ + 'operation_type|操作类型' => 'require|max:50', + 'operation_desc|操作说明' => 'require|max:500', + 'user_id|用户ID' => 'require|integer|egt:0', + 'user_nickname|用户昵称' => 'max:100', + 'data_type|数据类型' => 'max:100', + 'data_id|数据ID' => 'max:100', + 'related_type|关联类型' => 'max:100', + 'related_id|关联ID' => 'max:100', + 'request_method|请求方法' => 'max:10', + 'request_url|请求URL' => 'max:500', + 'request_ip|请求IP' => 'max:50', + 'user_agent|用户代理' => 'max:500', + ]; + } + + /** + * 获取额外数据 + * @param $value + * @return array + */ + public function getExtraDataAttr($value): array + { + if (is_string($value)) { + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : []; + } + return is_array($value) ? $value : []; + } + + /** + * 设置额外数据 + * @param $value + * @return string + */ + public function setExtraDataAttr($value): string + { + return is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE) : (string)$value; + } + + /** + * 获取操作类型标签样式 + * @param $value + * @param $data + * @return array + */ + public function getOperationTypeLabelAttr($value, $data): array + { + $typeLabels = [ + '创建' => ['class' => 'success', 'text' => '创建'], + '读取' => ['class' => 'info', 'text' => '读取'], + '更新' => ['class' => 'warning', 'text' => '更新'], + '删除' => ['class' => 'danger', 'text' => '删除'], + '导出' => ['class' => 'primary', 'text' => '导出'], + '登录' => ['class' => 'success', 'text' => '登录'], + '登出' => ['class' => 'default', 'text' => '登出'], + '审核' => ['class' => 'warning', 'text' => '审核'], + '发布' => ['class' => 'primary', 'text' => '发布'], + ]; + + $operationType = $data['operation_type'] ?? ''; + return $typeLabels[$operationType] ?? ['class' => 'default', 'text' => $operationType]; + } + + /** + * 获取格式化创建时间 + * @param $value + * @param $data + * @return string + */ + public function getCreatedAtTextAttr($value, $data): string + { + if (!empty($data['created_at'])) { + return date('Y-m-d H:i:s', strtotime($data['created_at'])); + } + return ''; + } + + /** + * 获取用户简短信息 + * @param $value + * @param $data + * @return string + */ + public function getUserInfoAttr($value, $data): string + { + $nickname = $data['user_nickname'] ?? ''; + $userId = $data['user_id'] ?? 0; + + if (!empty($nickname)) { + return $nickname . " (ID:{$userId})"; + } + return "用户ID:{$userId}"; + } + + /** + * 获取数据信息 + * @param $value + * @param $data + * @return string + */ + public function getDataInfoAttr($value, $data): string + { + $dataType = $data['data_type'] ?? ''; + $dataId = $data['data_id'] ?? ''; + + if (!empty($dataType) && !empty($dataId)) { + return "{$dataType}:{$dataId}"; + } elseif (!empty($dataType)) { + return $dataType; + } + return ''; + } + + /** + * 获取关联数据信息 + * @param $value + * @param $data + * @return string + */ + public function getRelatedInfoAttr($value, $data): string + { + $relatedType = $data['related_type'] ?? ''; + $relatedId = $data['related_id'] ?? ''; + + if (!empty($relatedType) && !empty($relatedId)) { + return "{$relatedType}:{$relatedId}"; + } elseif (!empty($relatedType)) { + return $relatedType; + } + return ''; + } + + /** + * 作用域查询 - 按用户ID + * @param $query + * @param $userId + * @return mixed + */ + public function scopeByUser($query, $userId) + { + return $query->where('user_id', $userId); + } + + /** + * 作用域查询 - 按操作类型 + * @param $query + * @param $operationType + * @return mixed + */ + public function scopeByOperationType($query, $operationType) + { + if (is_array($operationType)) { + return $query->whereIn('operation_type', $operationType); + } + return $query->where('operation_type', $operationType); + } + + /** + * 作用域查询 - 按数据类型和ID + * @param $query + * @param $dataType + * @param $dataId + * @return mixed + */ + public function scopeByData($query, $dataType, $dataId = null) + { + $query = $query->where('data_type', $dataType); + if (!is_null($dataId)) { + $query = $query->where('data_id', $dataId); + } + return $query; + } + + /** + * 作用域查询 - 按日期范围 + * @param $query + * @param $startDate + * @param $endDate + * @return mixed + */ + public function scopeByDateRange($query, $startDate, $endDate = null) + { + if (!empty($startDate)) { + $query = $query->where('created_at', '>=', $startDate); + } + if (!empty($endDate)) { + $query = $query->where('created_at', '<=', $endDate); + } + return $query; + } + + /** + * 作用域查询 - 最近记录 + * @param $query + * @param int $limit + * @return mixed + */ + public function scopeRecent($query, int $limit = 10) + { + return $query->order('created_at desc')->limit($limit); + } +} \ No newline at end of file diff --git a/src/service/RecorderService.php b/src/service/RecorderService.php new file mode 100644 index 0000000..ed35f2e --- /dev/null +++ b/src/service/RecorderService.php @@ -0,0 +1,610 @@ + true, + 'auto_record' => [ + 'enabled' => true, + 'exclude_operations' => ['读取'], + 'exclude_controllers' => [], + ], + 'retention_days' => 90, + 'sensitive_operations' => ['删除', '导出'], + 'view' => [ + 'default_limit' => 10, + 'date_format' => 'Y-m-d H:i:s', + 'theme' => 'default', + 'show_user' => true, + 'show_ip' => false, + 'compact_mode' => false, + ] + ]); + } + } + + /** + * 记录操作 + * @param array $data 操作记录数据 + * @return bool + */ + public static function record(array $data): bool + { + static::initConfig(); + + // 检查是否启用记录功能 + if (!static::$config['enabled']) { + return false; + } + + try { + // 自动补充请求信息 + $data = static::fillRequestInfo($data); + + // 自动补充用户信息 + $data = static::fillUserInfo($data); + + // 验证数据 + static::validateData($data); + + // 创建记录 + $model = new RecorderLog(); + return $model->save($data) !== false; + + } catch (\Exception $e) { + // 记录失败不应影响主业务,只记录到日志 + trace("操作记录失败: " . $e->getMessage(), 'error'); + return false; + } + } + + /** + * 批量记录操作 + * @param array $records 操作记录数组 + * @return bool + */ + public static function batchRecord(array $records): bool + { + $success = 0; + foreach ($records as $record) { + if (static::record($record)) { + $success++; + } + } + return $success > 0; + } + + /** + * 自动记录操作(填充当前请求上下文) + * @param array $data + * @return bool + */ + public static function autoRecord(array $data): bool + { + // 自动记录会补充更多上下文信息 + $data = array_merge([ + 'request_method' => Request::method(), + 'request_url' => Request::url(true), + 'request_ip' => Request::ip(), + 'user_agent' => Request::header('user-agent', ''), + ], $data); + + return static::record($data); + } + + /** + * 查询操作记录 + * @param array $conditions 查询条件 + * @return array + */ + public static function query(array $conditions = []): array + { + $model = new RecorderLog(); + $query = $model->newQuery(); + + // 构建查询条件 + static::buildQueryConditions($query, $conditions); + + // 排序和分页 + $limit = $conditions['limit'] ?? 20; + $page = $conditions['page'] ?? 1; + + $result = $query->order('created_at desc') + ->page($page, $limit) + ->select() + ->toArray(); + + return [ + 'data' => $result, + 'total' => $query->count(), + 'page' => $page, + 'limit' => $limit + ]; + } + + /** + * 获取用户操作记录 + * @param int $userId 用户ID + * @param array $conditions 额外条件 + * @return array + */ + public static function getUserRecords(int $userId, array $conditions = []): array + { + $conditions['user_id'] = $userId; + return static::query($conditions); + } + + /** + * 获取数据操作记录 + * @param string $dataType 数据类型 + * @param string $dataId 数据ID + * @param array $conditions 额外条件 + * @return array + */ + public static function getDataRecords(string $dataType, string $dataId = '', array $conditions = []): array + { + $conditions['data_type'] = $dataType; + if (!empty($dataId)) { + $conditions['data_id'] = $dataId; + } + return static::query($conditions); + } + + /** + * 获取操作统计 + * @param array $conditions 查询条件 + * @return array + */ + public static function getStats(array $conditions = []): array + { + $model = new RecorderLog(); + $query = $model->newQuery(); + + static::buildQueryConditions($query, $conditions); + + // 按操作类型统计 + $typeStats = $query->field('operation_type, COUNT(*) as count') + ->group('operation_type') + ->select() + ->toArray(); + + // 按用户统计 + $userStats = $query->field('user_id, user_nickname, COUNT(*) as count') + ->group('user_id, user_nickname') + ->order('count desc') + ->limit(10) + ->select() + ->toArray(); + + // 总记录数 + $totalCount = $query->count(); + + return [ + 'total_count' => $totalCount, + 'type_stats' => $typeStats, + 'user_stats' => $userStats, + ]; + } + + /** + * 导出操作记录 + * @param array $conditions 查询条件 + * @return string 文件路径 + */ + public static function export(array $conditions = []): string + { + $records = static::query(array_merge($conditions, ['limit' => 10000])); + + // 生成CSV文件 + $filename = 'recorder_log_' . date('Y-m-d_H-i-s') . '.csv'; + $filepath = runtime_path('temp') . $filename; + + $file = fopen($filepath, 'w'); + + // 写入表头 + $headers = ['ID', '操作类型', '操作说明', '用户ID', '用户昵称', '数据类型', + '数据ID', '关联类型', '关联ID', '请求方法', '请求URL', + '请求IP', '用户代理', '创建时间']; + fputcsv($file, $headers); + + // 写入数据 + foreach ($records['data'] as $record) { + $row = [ + $record['id'], + $record['operation_type'], + $record['operation_desc'], + $record['user_id'], + $record['user_nickname'], + $record['data_type'], + $record['data_id'], + $record['related_type'], + $record['related_id'], + $record['request_method'], + $record['request_url'], + $record['request_ip'], + $record['user_agent'], + $record['created_at'], + ]; + fputcsv($file, $row); + } + + fclose($file); + + return $filepath; + } + + /** + * 清理过期记录 + * @param int $days 保留天数 + * @return int 清理数量 + */ + public static function cleanup(int $days = 90): int + { + if ($days <= 0) { + static::initConfig(); + $days = static::$config['retention_days'] ?? 90; + } + + $expiredDate = date('Y-m-d H:i:s', strtotime("-{$days} days")); + + $model = new RecorderLog(); + return $model->where('created_at', '<', $expiredDate)->delete(); + } + + /** + * 填充请求信息 + * @param array $data + * @return array + */ + protected static function fillRequestInfo(array $data): array + { + if (empty($data['request_method'])) { + $data['request_method'] = Request::method() ?? ''; + } + if (empty($data['request_url'])) { + $data['request_url'] = Request::url(true) ?? ''; + } + if (empty($data['request_ip'])) { + $data['request_ip'] = Request::ip() ?? ''; + } + if (empty($data['user_agent'])) { + $data['user_agent'] = Request::header('user-agent', ''); + } + + return $data; + } + + /** + * 填充用户信息 + * @param array $data + * @return array + */ + protected static function fillUserInfo(array $data): array + { + // 如果没有提供用户信息,尝试从当前会话获取 + if (empty($data['user_id']) || empty($data['user_nickname'])) { + try { + // 尝试获取当前登录用户信息 + $adminUser = session('user'); + if (!empty($adminUser)) { + if (empty($data['user_id'])) { + $data['user_id'] = $adminUser['id'] ?? 0; + } + if (empty($data['user_nickname'])) { + $data['user_nickname'] = $adminUser['username'] ?? $adminUser['nickname'] ?? ''; + } + } + } catch (\Exception $e) { + // 获取用户信息失败,使用默认值 + $data['user_id'] = $data['user_id'] ?? 0; + $data['user_nickname'] = $data['user_nickname'] ?? '未知用户'; + } + } + + return $data; + } + + /** + * 验证数据 + * @param array $data + * @throws ValidateException + */ + protected static function validateData(array $data) + { + $rules = [ + 'operation_type' => 'require', + 'operation_desc' => 'require', + ]; + + $messages = [ + 'operation_type.require' => '操作类型不能为空', + 'operation_desc.require' => '操作说明不能为空', + ]; + + $validate = validate($rules, $messages); + if (!$validate->check($data)) { + throw new ValidateException($validate->getError()); + } + } + + /** + * 构建查询条件 + * @param $query + * @param array $conditions + */ + protected static function buildQueryConditions($query, array $conditions) + { + // 用户ID条件 + if (!empty($conditions['user_id'])) { + $query->byUser($conditions['user_id']); + } + + // 操作类型条件 + if (!empty($conditions['operation_type'])) { + $query->byOperationType($conditions['operation_type']); + } + + // 数据类型和ID条件 + if (!empty($conditions['data_type'])) { + $dataId = $conditions['data_id'] ?? null; + $query->byData($conditions['data_type'], $dataId); + } + + // 日期范围条件 + if (!empty($conditions['start_time']) || !empty($conditions['end_time'])) { + $startTime = $conditions['start_time'] ?? null; + $endTime = $conditions['end_time'] ?? null; + $query->byDateRange($startTime, $endTime); + } + + // 日期范围条件 (兼容date_range参数) + if (!empty($conditions['date_range']) && is_array($conditions['date_range'])) { + $startTime = $conditions['date_range'][0] ?? null; + $endTime = $conditions['date_range'][1] ?? null; + $query->byDateRange($startTime, $endTime); + } + + // IP地址条件 + if (!empty($conditions['request_ip'])) { + $query->where('request_ip', $conditions['request_ip']); + } + + // 关键词搜索 + if (!empty($conditions['keyword'])) { + $keyword = $conditions['keyword']; + $query->where(function($q) use ($keyword) { + $q->whereLike('operation_desc', "%{$keyword}%") + ->whereOr('user_nickname', 'like', "%{$keyword}%") + ->whereOr('data_type', 'like', "%{$keyword}%"); + }); + } + } + + /** + * 获取数据的读取用户列表(去重) + * @param string $dataType 数据类型 + * @param string $dataId 数据ID + * @param array $conditions 额外条件 + * @return array + */ + public static function getDataReaders(string $dataType, string $dataId, array $conditions = []): array + { + try { + $model = new RecorderLog(); + $query = $model->newQuery(); + + // 基础查询条件 + $query->where('data_type', $dataType) + ->where('data_id', $dataId) + ->where('operation_type', '读取'); // 只查询读取操作 + + // 额外条件 + if (!empty($conditions['start_time'])) { + $query->where('created_at', '>=', $conditions['start_time']); + } + if (!empty($conditions['end_time'])) { + $query->where('created_at', '<=', $conditions['end_time']); + } + + // 按用户分组,获取每个用户的首次和最后一次读取时间 + $readers = $query->field([ + 'user_id', + 'user_nickname', + 'MIN(created_at) as first_read_at', + 'MAX(created_at) as last_read_at', + 'COUNT(*) as read_count', + 'MAX(request_ip) as last_ip', + 'MAX(user_agent) as last_user_agent' + ]) + ->group('user_id, user_nickname') + ->order('last_read_at desc') + ->select() + ->toArray(); + + // 格式化数据 + foreach ($readers as &$reader) { + $reader['first_read_at_formatted'] = date('Y-m-d H:i:s', strtotime($reader['first_read_at'])); + $reader['last_read_at_formatted'] = date('Y-m-d H:i:s', strtotime($reader['last_read_at'])); + $reader['first_read_at_relative'] = static::getRelativeTime($reader['first_read_at']); + $reader['last_read_at_relative'] = static::getRelativeTime($reader['last_read_at']); + } + + return $readers; + + } catch (\Exception $e) { + trace("获取数据读取用户失败: " . $e->getMessage(), 'error'); + return []; + } + } + + /** + * 获取数据的操作用户列表(排除读取操作) + * @param string $dataType 数据类型 + * @param string $dataId 数据ID + * @param array $conditions 额外条件 + * @return array + */ + public static function getDataOperators(string $dataType, string $dataId, array $conditions = []): array + { + try { + $model = new RecorderLog(); + $query = $model->newQuery(); + + // 基础查询条件 + $query->where('data_type', $dataType) + ->where('data_id', $dataId) + ->where('operation_type', '<>', '读取'); // 排除读取操作 + + // 额外条件 + if (!empty($conditions['start_time'])) { + $query->where('created_at', '>=', $conditions['start_time']); + } + if (!empty($conditions['end_time'])) { + $query->where('created_at', '<=', $conditions['end_time']); + } + + // 按用户和操作类型分组 + $operators = $query->field([ + 'user_id', + 'user_nickname', + 'operation_type', + 'MIN(created_at) as first_operation_at', + 'MAX(created_at) as last_operation_at', + 'COUNT(*) as operation_count', + 'MAX(operation_desc) as last_operation_desc', + 'MAX(request_ip) as last_ip' + ]) + ->group('user_id, user_nickname, operation_type') + ->order('last_operation_at desc') + ->select() + ->toArray(); + + // 按用户聚合操作信息 + $userOperations = []; + foreach ($operators as $operator) { + $userId = $operator['user_id']; + + if (!isset($userOperations[$userId])) { + $userOperations[$userId] = [ + 'user_id' => $operator['user_id'], + 'user_nickname' => $operator['user_nickname'], + 'operations' => [], + 'total_operations' => 0, + 'first_operation_at' => $operator['first_operation_at'], + 'last_operation_at' => $operator['last_operation_at'], + 'last_ip' => $operator['last_ip'] + ]; + } + + // 添加操作信息 + $userOperations[$userId]['operations'][] = [ + 'type' => $operator['operation_type'], + 'count' => $operator['operation_count'], + 'last_desc' => $operator['last_operation_desc'], + 'first_at' => $operator['first_operation_at'], + 'last_at' => $operator['last_operation_at'] + ]; + + $userOperations[$userId]['total_operations'] += $operator['operation_count']; + + // 更新最早和最晚时间 + if ($operator['first_operation_at'] < $userOperations[$userId]['first_operation_at']) { + $userOperations[$userId]['first_operation_at'] = $operator['first_operation_at']; + } + if ($operator['last_operation_at'] > $userOperations[$userId]['last_operation_at']) { + $userOperations[$userId]['last_operation_at'] = $operator['last_operation_at']; + } + } + + // 格式化数据并转为数组 + $result = array_values($userOperations); + foreach ($result as &$user) { + $user['first_operation_at_formatted'] = date('Y-m-d H:i:s', strtotime($user['first_operation_at'])); + $user['last_operation_at_formatted'] = date('Y-m-d H:i:s', strtotime($user['last_operation_at'])); + $user['first_operation_at_relative'] = static::getRelativeTime($user['first_operation_at']); + $user['last_operation_at_relative'] = static::getRelativeTime($user['last_operation_at']); + + // 生成操作摘要 + $operationTypes = array_column($user['operations'], 'type'); + $user['operation_summary'] = implode('、', array_unique($operationTypes)); + } + + // 按最后操作时间排序 + usort($result, function($a, $b) { + return strtotime($b['last_operation_at']) - strtotime($a['last_operation_at']); + }); + + return $result; + + } catch (\Exception $e) { + trace("获取数据操作用户失败: " . $e->getMessage(), 'error'); + return []; + } + } + + /** + * 获取相对时间 + * @param string $datetime + * @return string + */ + protected static function getRelativeTime(string $datetime): string + { + $time = strtotime($datetime); + $now = time(); + $diff = $now - $time; + + if ($diff < 60) { + return '刚刚'; + } elseif ($diff < 3600) { + return intval($diff / 60) . '分钟前'; + } elseif ($diff < 86400) { + return intval($diff / 3600) . '小时前'; + } elseif ($diff < 604800) { + return intval($diff / 86400) . '天前'; + } else { + return date('m-d', $time); + } + } + + /** + * 获取配置 + * @param string $key + * @param mixed $default + * @return mixed + */ + public static function getConfig(string $key = '', $default = null) + { + static::initConfig(); + + if (empty($key)) { + return static::$config; + } + + return static::$config[$key] ?? $default; + } +} \ No newline at end of file diff --git a/stc/database/20241212000001_create_recorder_logs_table.php b/stc/database/20241212000001_create_recorder_logs_table.php new file mode 100644 index 0000000..28e2fb8 --- /dev/null +++ b/stc/database/20241212000001_create_recorder_logs_table.php @@ -0,0 +1,112 @@ +table('recorder_log', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'collation' => 'utf8mb4_general_ci', + 'comment' => '操作记录表', + ]); + + $table->addColumn('id', 'biginteger', [ + 'identity' => true, + 'signed' => false, + 'comment' => '记录ID', + ]) + ->addColumn('operation_type', 'string', [ + 'limit' => 50, + 'default' => '', + 'comment' => '操作类型', + ]) + ->addColumn('operation_desc', 'string', [ + 'limit' => 500, + 'default' => '', + 'comment' => '操作说明', + ]) + ->addColumn('user_id', 'biginteger', [ + 'signed' => false, + 'default' => 0, + 'comment' => '操作用户ID', + ]) + ->addColumn('user_nickname', 'string', [ + 'limit' => 100, + 'default' => '', + 'comment' => '操作用户昵称', + ]) + ->addColumn('data_type', 'string', [ + 'limit' => 100, + 'default' => '', + 'comment' => '操作数据类型', + ]) + ->addColumn('data_id', 'string', [ + 'limit' => 100, + 'default' => '', + 'comment' => '操作数据ID', + ]) + ->addColumn('related_type', 'string', [ + 'limit' => 100, + 'default' => '', + 'comment' => '关联数据类型', + ]) + ->addColumn('related_id', 'string', [ + 'limit' => 100, + 'default' => '', + 'comment' => '关联数据ID', + ]) + ->addColumn('request_method', 'string', [ + 'limit' => 10, + 'default' => '', + 'comment' => '请求方法', + ]) + ->addColumn('request_url', 'string', [ + 'limit' => 500, + 'default' => '', + 'comment' => '请求URL', + ]) + ->addColumn('request_ip', 'string', [ + 'limit' => 50, + 'default' => '', + 'comment' => '请求IP', + ]) + ->addColumn('user_agent', 'string', [ + 'limit' => 500, + 'default' => '', + 'comment' => '用户代理', + ]) + ->addColumn('extra_data', 'text', [ + 'null' => true, + 'comment' => '额外数据(JSON格式)', + ]) + ->addColumn('created_at', 'datetime', [ + 'default' => 'CURRENT_TIMESTAMP', + 'comment' => '创建时间', + ]) + ->addColumn('updated_at', 'datetime', [ + 'default' => 'CURRENT_TIMESTAMP', + 'update' => 'CURRENT_TIMESTAMP', + 'comment' => '更新时间', + ]) + ->addIndex(['user_id'], ['name' => 'idx_user_id']) + ->addIndex(['operation_type'], ['name' => 'idx_operation_type']) + ->addIndex(['data_type', 'data_id'], ['name' => 'idx_data_type_id']) + ->addIndex(['created_at'], ['name' => 'idx_created_at']) + ->create(); + } + + /** + * 删除操作记录表 + */ + public function down() + { + $this->table('recorder_log')->drop()->save(); + } +} \ No newline at end of file diff --git a/test_example.php b/test_example.php new file mode 100644 index 0000000..0cf7400 --- /dev/null +++ b/test_example.php @@ -0,0 +1,232 @@ + '测试', + 'operation_desc' => '测试插件基础记录功能', + 'user_id' => 1, + 'user_nickname' => '测试用户', + 'data_type' => 'test', + 'data_id' => '001' + ]); + + echo $result ? "✅ 基础记录成功\n" : "❌ 基础记录失败\n"; +} catch (Exception $e) { + echo "❌ 基础记录异常: " . $e->getMessage() . "\n"; +} +echo "\n"; + +// 测试2: 自动记录功能 +echo "测试2: 自动记录功能\n"; +echo "--------------------\n"; + +try { + $result = RecorderService::autoRecord([ + 'operation_type' => '自动测试', + 'operation_desc' => '测试插件自动记录功能', + 'data_type' => 'test', + 'data_id' => '002', + 'extra_data' => ['test' => true, 'timestamp' => time()] + ]); + + echo $result ? "✅ 自动记录成功\n" : "❌ 自动记录失败\n"; +} catch (Exception $e) { + echo "❌ 自动记录异常: " . $e->getMessage() . "\n"; +} +echo "\n"; + +// 测试3: 批量记录功能 +echo "测试3: 批量记录功能\n"; +echo "--------------------\n"; + +try { + $records = [ + [ + 'operation_type' => '批量测试1', + 'operation_desc' => '批量记录测试1', + 'user_id' => 1, + 'user_nickname' => '测试用户', + 'data_type' => 'batch_test', + 'data_id' => 'b001' + ], + [ + 'operation_type' => '批量测试2', + 'operation_desc' => '批量记录测试2', + 'user_id' => 1, + 'user_nickname' => '测试用户', + 'data_type' => 'batch_test', + 'data_id' => 'b002' + ], + [ + 'operation_type' => '批量测试3', + 'operation_desc' => '批量记录测试3', + 'user_id' => 1, + 'user_nickname' => '测试用户', + 'data_type' => 'batch_test', + 'data_id' => 'b003' + ] + ]; + + $result = RecorderService::batchRecord($records); + echo $result ? "✅ 批量记录成功\n" : "❌ 批量记录失败\n"; +} catch (Exception $e) { + echo "❌ 批量记录异常: " . $e->getMessage() . "\n"; +} +echo "\n"; + +// 测试4: 查询功能 +echo "测试4: 查询功能\n"; +echo "---------------\n"; + +try { + // 查询最近的记录 + $recentRecords = RecorderService::query(['limit' => 5]); + echo "📊 查询到 " . count($recentRecords['data']) . " 条最近记录\n"; + + foreach ($recentRecords['data'] as $record) { + echo " - {$record['operation_type']}: {$record['operation_desc']} ({$record['created_at']})\n"; + } + + // 按用户查询 + $userRecords = RecorderService::getUserRecords(1, ['limit' => 3]); + echo "📊 查询到用户1的 " . count($userRecords['data']) . " 条记录\n"; + + // 按数据类型查询 + $dataRecords = RecorderService::getDataRecords('test'); + echo "📊 查询到test类型的 " . count($dataRecords['data']) . " 条记录\n"; + +} catch (Exception $e) { + echo "❌ 查询异常: " . $e->getMessage() . "\n"; +} +echo "\n"; + +// 测试5: 统计功能 +echo "测试5: 统计功能\n"; +echo "---------------\n"; + +try { + $stats = RecorderService::getStats(); + echo "📈 统计信息:\n"; + echo " - 总记录数: " . $stats['total_count'] . "\n"; + echo " - 操作类型统计:\n"; + + foreach ($stats['type_stats'] as $typeStat) { + echo " * {$typeStat['operation_type']}: {$typeStat['count']} 次\n"; + } + + echo " - 用户操作统计 (Top 5):\n"; + foreach (array_slice($stats['user_stats'], 0, 5) as $userStat) { + echo " * {$userStat['user_nickname']} (ID:{$userStat['user_id']}): {$userStat['count']} 次\n"; + } + +} catch (Exception $e) { + echo "❌ 统计异常: " . $e->getMessage() . "\n"; +} +echo "\n"; + +// 测试6: 视图辅助功能 +echo "测试6: 视图辅助功能\n"; +echo "-------------------\n"; + +try { + // 测试获取记录数据 + $viewRecords = ViewHelper::getRecords(['limit' => 3]); + echo "🎨 视图辅助获取到 " . count($viewRecords) . " 条记录\n"; + + // 测试渲染功能(输出HTML长度作为测试) + $listHtml = ViewHelper::renderList(['limit' => 2], ['theme' => 'default']); + echo "🎨 默认列表HTML长度: " . strlen($listHtml) . " 字符\n"; + + $cardHtml = ViewHelper::renderList(['limit' => 2], ['theme' => 'card']); + echo "🎨 卡片列表HTML长度: " . strlen($cardHtml) . " 字符\n"; + + $timelineHtml = ViewHelper::renderTimeline(['limit' => 2]); + echo "🎨 时间线HTML长度: " . strlen($timelineHtml) . " 字符\n"; + + if (!empty($viewRecords)) { + $itemHtml = ViewHelper::renderItem($viewRecords[0]); + echo "🎨 单项HTML长度: " . strlen($itemHtml) . " 字符\n"; + } + +} catch (Exception $e) { + echo "❌ 视图辅助异常: " . $e->getMessage() . "\n"; +} +echo "\n"; + +// 测试7: 配置测试 +echo "测试7: 配置测试\n"; +echo "---------------\n"; + +try { + $config = RecorderService::getConfig(); + echo "⚙️ 插件配置信息:\n"; + echo " - 启用状态: " . ($config['enabled'] ? '已启用' : '已禁用') . "\n"; + echo " - 自动记录: " . ($config['auto_record']['enabled'] ? '已启用' : '已禁用') . "\n"; + echo " - 记录保留天数: " . $config['retention_days'] . " 天\n"; + echo " - 默认显示主题: " . $config['view']['theme'] . "\n"; + +} catch (Exception $e) { + echo "❌ 配置测试异常: " . $e->getMessage() . "\n"; +} +echo "\n"; + +// 测试8: 数据验证 +echo "测试8: 数据验证\n"; +echo "---------------\n"; + +// 测试必填字段验证 +try { + $result = RecorderService::record([ + // 缺少必填字段 operation_type + 'operation_desc' => '测试数据验证' + ]); + echo "❌ 数据验证失败 - 应该抛出异常但没有\n"; +} catch (Exception $e) { + echo "✅ 数据验证正常 - 正确检测到必填字段缺失\n"; +} + +// 测试空操作描述 +try { + $result = RecorderService::record([ + 'operation_type' => '验证测试', + // 缺少必填字段 operation_desc + ]); + echo "❌ 数据验证失败 - 应该抛出异常但没有\n"; +} catch (Exception $e) { + echo "✅ 数据验证正常 - 正确检测到操作描述缺失\n"; +} +echo "\n"; + +echo "=== 测试完成 ===\n"; +echo "✅ 所有测试已执行完毕\n"; +echo "📝 请查看上述输出结果,确认各项功能是否正常\n"; +echo "💡 如有异常,请检查数据库连接和表结构是否正确\n\n"; + +// 输出一些调试信息 +echo "调试信息:\n"; +echo "- 当前时间: " . date('Y-m-d H:i:s') . "\n"; +echo "- PHP版本: " . PHP_VERSION . "\n"; +echo "- 内存使用: " . round(memory_get_usage() / 1024 / 1024, 2) . " MB\n"; +echo "\n"; + +echo "🎉 插件测试脚本执行完成!\n"; +echo "📖 更多使用方法请参考 README.md 文件\n"; +?> \ No newline at end of file diff --git a/test_functions.php b/test_functions.php new file mode 100644 index 0000000..d2ca2f9 --- /dev/null +++ b/test_functions.php @@ -0,0 +1,94 @@ + 1]); + echo "✅ recorder_get_records() 调用成功\n"; + } catch (Exception $e) { + echo "⚠️ recorder_get_records() 调用失败: " . $e->getMessage() . "\n"; + } + + try { + // 测试渲染空列表 + $html = recorder_render_list([], ['limit' => 0]); + if (!empty($html)) { + echo "✅ recorder_render_list() 调用成功,HTML长度: " . strlen($html) . "\n"; + } else { + echo "⚠️ recorder_render_list() 返回空内容\n"; + } + } catch (Exception $e) { + echo "⚠️ recorder_render_list() 调用失败: " . $e->getMessage() . "\n"; + } +} else { + echo "❌ 函数未正确注册,无法进行调用测试\n"; +} + +echo "\n=== 解决方案 ===\n"; +echo "如果函数未注册,请按以下步骤操作:\n\n"; +echo "1. 更新Composer自动加载:\n"; +echo " composer dump-autoload\n\n"; +echo "2. 在模板中使用前手动引入:\n"; +echo " {:include_once(app_path('plugs/think-plugs-recorder/src/functions.php'))}\n\n"; +echo "3. 或在应用启动文件中引入:\n"; +echo " // app/provider.php 或 common.php\n"; +echo " require_once app_path('plugs/think-plugs-recorder/src/functions.php');\n\n"; + +echo "=== 测试完成 ===\n"; +?> \ No newline at end of file diff --git a/view/recorder/components/data_operators.html b/view/recorder/components/data_operators.html new file mode 100644 index 0000000..68dbd3a --- /dev/null +++ b/view/recorder/components/data_operators.html @@ -0,0 +1,227 @@ +
+
+ {if isset($operators) && !empty($operators)} +
+
+ + 操作用户 ({$operators|count}) +
+
+ +
+ {volist name="operators" id="operator"} +
+
+ +
+
+
{$operator.user_nickname}
+
{$operator.operation_summary}
+
+ + + 操作 {$operator.total_operations} 次 + + + + 最后: {$operator.last_operation_at_relative} + +
+
+ + 首次: {$operator.first_operation_at_formatted} + | 最后: {$operator.last_operation_at_formatted} + +
+ {if isset($operator.operations) && !empty($operator.operations)} +
+ {volist name="operator.operations" id="op"} + + {$op.type} ({$op.count}) + + {/volist} +
+ {/if} +
+
+ {/volist} +
+ {else} +
+ +

暂无用户操作过此数据

+
+ {/if} +
+
+ + + + \ No newline at end of file diff --git a/view/recorder/components/data_readers.html b/view/recorder/components/data_readers.html new file mode 100644 index 0000000..e7ecc0e --- /dev/null +++ b/view/recorder/components/data_readers.html @@ -0,0 +1,180 @@ +
+
+ {if isset($readers) && !empty($readers)} +
+
+ + 读取用户 ({$readers|count}) +
+
+ +
+ {volist name="readers" id="reader"} +
+
+ +
+
+
{$reader.user_nickname}
+
+ + + 读取 {$reader.read_count} 次 + + {if $reader.first_read_at != $reader.last_read_at} + + + 最后: {$reader.last_read_at_relative} + + {else/} + + + {$reader.first_read_at_relative} + + {/if} +
+
+ + 首次: {$reader.first_read_at_formatted} + {if $reader.first_read_at != $reader.last_read_at} + | 最后: {$reader.last_read_at_formatted} + {/if} + +
+
+
+ {/volist} +
+ {else} +
+ +

暂无用户读取过此数据

+
+ {/if} +
+
+ + + + \ No newline at end of file diff --git a/view/recorder/components/record_item_default.html b/view/recorder/components/record_item_default.html new file mode 100644 index 0000000..014cd88 --- /dev/null +++ b/view/recorder/components/record_item_default.html @@ -0,0 +1,301 @@ +
+ {if isset($record) && !empty($record)} +
+
+ + + {$record.operation_type_label.text} + +
+
+
+ {$record.created_at_relative|default=''} +
+
+ {$record.created_at_formatted|default=''} +
+
+
+ +
+
+ {$record.operation_desc|default=''} +
+ +
+ {if $options.show_user|default=true && !empty($record.user_info)} +
+ + 操作人: + {$record.user_info} +
+ {/if} + + {if !empty($record.data_info)} +
+ + 操作对象: + {$record.data_info} +
+ {/if} + + {if !empty($record.related_info)} +
+ + 关联数据: + {$record.related_info} +
+ {/if} + + {if $options.show_ip|default=false && !empty($record.request_ip)} +
+ + IP地址: + {$record.request_ip} +
+ {/if} + + {if !empty($record.request_url)} +
+ + 请求地址: + + {$record.request_url} + +
+ {/if} +
+ + {if $options.show_extra|default=false && !empty($record.extra_data)} +
+
+ + + 查看详细信息 + +
+
{:json_encode($record.extra_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)}
+
+
+
+ {/if} +
+ {else} +
+ +

记录不存在

+
+ {/if} +
+ + \ No newline at end of file diff --git a/view/recorder/components/record_list_card.html b/view/recorder/components/record_list_card.html new file mode 100644 index 0000000..c9fe88c --- /dev/null +++ b/view/recorder/components/record_list_card.html @@ -0,0 +1,200 @@ +
+ {if isset($records) && !empty($records)} +
+ {volist name="records" id="record"} +
+
+
+ + {$record.operation_type_label.text} +
+
+ {$record.created_at_relative} +
+
+
+
{$record.operation_desc}
+
+ {if $options.show_user && !empty($record.user_info)} +
+ + {$record.user_info} +
+ {/if} + {if !empty($record.data_info)} +
+ + {$record.data_info} +
+ {/if} + {if $options.show_ip && !empty($record.request_ip)} +
+ + {$record.request_ip} +
+ {/if} +
+
+ +
+ {/volist} +
+ {else} +
+ +

暂无操作记录

+
+ {/if} +
+ + \ No newline at end of file diff --git a/view/recorder/components/record_list_default.html b/view/recorder/components/record_list_default.html new file mode 100644 index 0000000..fd8216a --- /dev/null +++ b/view/recorder/components/record_list_default.html @@ -0,0 +1,206 @@ +
+
+ {if isset($records) && !empty($records)} + {volist name="records" id="record"} +
+ + {if $options.theme == 'card'} +
+ {/if} + +
+ + {$record.operation_type_label.text} + + + {$record.created_at_relative} + +
+
+
{$record.operation_desc}
+ {if $options.show_user && !empty($record.user_info)} +
+ {$record.user_info} +
+ {/if} + {if !empty($record.data_info)} +
+ {$record.data_info} +
+ {/if} + {if !empty($record.related_info)} + + {/if} + {if $options.show_ip && !empty($record.request_ip)} +
+ {$record.request_ip} +
+ {/if} + {if $options.show_extra && !empty($record.extra_data)} +
+ +
+ 额外信息 +
{:json_encode($record.extra_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)}
+
+
+ {/if} +
+
+ {/volist} + {else} +
+ +

暂无操作记录

+
+ {/if} +
+
+ + + + \ No newline at end of file diff --git a/view/recorder/components/record_timeline.html b/view/recorder/components/record_timeline.html new file mode 100644 index 0000000..2798d27 --- /dev/null +++ b/view/recorder/components/record_timeline.html @@ -0,0 +1,245 @@ +
+
+ {if isset($records) && !empty($records)} +
+ +
+ + {volist name="records" id="record"} +
+ +
+ +
+ + +
+ + +
+
+ {$record.operation_type_label.text} + {$record.created_at_relative} +
+
{$record.created_at_formatted}
+
+ + +
+
{$record.operation_desc}
+ + {if !$options.compact} +
+ {if $options.show_user && !empty($record.user_info)} +
+ + 操作人: + {$record.user_info} +
+ {/if} + {if !empty($record.data_info)} +
+ + 操作对象: + {$record.data_info} +
+ {/if} + {if !empty($record.related_info)} +
+ + 关联数据: + {$record.related_info} +
+ {/if} + {if $options.show_ip && !empty($record.request_ip)} +
+ + IP地址: + {$record.request_ip} +
+ {/if} +
+ {/if} + + {if $options.show_extra && !empty($record.extra_data)} +
+
+ + + 查看详细信息 + +
+
{:json_encode($record.extra_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)}
+
+
+
+ {/if} +
+
+
+ {/volist} +
+ {else} +
+
+
+ +

暂无操作记录

+
+
+ {/if} +
+
+ + + + \ No newline at end of file