From f1056bab01449e03dd811b8265a40244f32cd1aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B9=98=E9=A3=8E?= Date: Mon, 5 Jan 2026 10:11:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MCP/MCP服务器使用说明.md | 233 ++ MCP/README.md | 70 + MCP/mcp.json.example | 27 + MCP/moncter-mcp-server/.gitignore | 7 + MCP/moncter-mcp-server/MCP接口对比分析.md | 194 ++ .../MCP服务器同步更新说明.md | 342 ++ MCP/moncter-mcp-server/MCP服务器更新说明.md | 246 ++ MCP/moncter-mcp-server/README.md | 122 + MCP/moncter-mcp-server/install.bat | 39 + MCP/moncter-mcp-server/install.sh | 38 + MCP/moncter-mcp-server/package-lock.json | 1103 +++++++ MCP/moncter-mcp-server/package.json | 26 + MCP/moncter-mcp-server/src/index.ts | 595 ++++ MCP/moncter-mcp-server/tsconfig.json | 20 + MCP/实现总结.md | 164 + MCP/快速开始.md | 212 ++ README.en.md | 36 - TaskShow/.editorconfig | 13 + TaskShow/.gitignore | 29 + TaskShow/README.md | 127 + TaskShow/USAGE.md | 154 + TaskShow/index.html | 14 + TaskShow/package-lock.json | 2846 +++++++++++++++++ TaskShow/package.json | 27 + TaskShow/pnpm-lock.yaml | 1794 +++++++++++ TaskShow/tsconfig.json | 32 + TaskShow/tsconfig.node.json | 12 + TaskShow/v0.39.1.tar.gz | Bin 0 -> 166840 bytes TaskShow/vite.config.ts | 25 + app/command/BatchUpdateTags.php | 163 + app/command/InitTags.php | 28 + app/controller/ConsumptionController.php | 110 + .../DataCollectionTaskController.php | 850 +++++ app/controller/DataSourceController.php | 173 + app/controller/DatabaseSyncController.php | 455 +++ app/controller/PersonMergeController.php | 169 + app/controller/TagCohortController.php | 308 ++ app/controller/TagController.php | 521 +++ app/controller/TagDefinitionController.php | 155 + app/controller/TagTaskController.php | 227 ++ app/process/DataSyncScheduler.php | 695 ++++ app/process/DataSyncWorker.php | 212 ++ app/process/TagCalculationWorker.php | 294 ++ .../ConsumptionRecordRepository.php | 90 + .../DataCollectionTaskRepository.php | 112 + app/repository/DataSourceRepository.php | 99 + app/repository/StoreRepository.php | 123 + app/repository/TagCohortRepository.php | 57 + app/repository/TagDefinitionRepository.php | 64 + app/repository/TagHistoryRepository.php | 52 + app/repository/TagTaskExecutionRepository.php | 86 + app/repository/TagTaskRepository.php | 95 + .../UserPhoneRelationRepository.php | 168 + app/repository/UserProfileRepository.php | 227 ++ app/repository/UserTagRepository.php | 62 + app/service/ConsumptionService.php | 282 ++ .../Handler/BaseCollectionHandler.php | 115 + .../Handler/ConsumptionCollectionHandler.php | 1760 ++++++++++ .../Handler/DatabaseSyncHandler.php | 427 +++ .../Handler/GenericCollectionHandler.php | 1360 ++++++++ .../DataCollection/Handler/TagTaskHandler.php | 74 + .../Trait/DataCollectionHelperTrait.php | 172 + app/service/DataCollectionTaskService.php | 660 ++++ .../DataSource/Adapter/MongoDBAdapter.php | 309 ++ .../DataSource/Adapter/MySQLAdapter.php | 234 ++ .../DataSource/DataSourceAdapterFactory.php | 116 + .../DataSource/DataSourceAdapterInterface.php | 73 + .../DataSource/PollingStrategyFactory.php | 68 + .../DataSource/PollingStrategyInterface.php | 54 + .../Strategy/DefaultConsumptionStrategy.php | 197 ++ .../Strategy/MongoDBConsumptionStrategy.php | 225 ++ app/service/DataSourceService.php | 498 +++ app/service/DataSyncService.php | 242 ++ app/service/DatabaseSyncService.php | 1417 ++++++++ app/service/IdentifierService.php | 312 ++ app/service/PersonMergeService.php | 497 +++ app/service/StoreService.php | 164 + app/service/TagInitService.php | 226 ++ .../TagRuleEngine/SimpleRuleEngine.php | 96 + app/service/TagService.php | 587 ++++ app/service/TagTaskExecutor.php | 331 ++ app/service/TagTaskService.php | 283 ++ app/service/UserPhoneService.php | 537 ++++ app/service/UserService.php | 395 +++ app/utils/ApiResponseHelper.php | 137 + app/utils/DataMaskingHelper.php | 143 + app/utils/EncryptionHelper.php | 141 + app/utils/IdCardHelper.php | 102 + app/utils/LogMaskingProcessor.php | 137 + app/utils/LoggerHelper.php | 155 + app/utils/MongoDBHelper.php | 55 + app/utils/QueueService.php | 247 ++ app/utils/RedisHelper.php | 267 ++ config/data_collection_tasks.php | 74 + config/data_sources.php | 63 + config/encryption.php | 60 + config/queue.php | 81 + public/database-sync-dashboard.html | 1024 ++++++ support/bootstrap/MongoDB.php | 73 + 99 files changed, 28576 insertions(+), 36 deletions(-) create mode 100644 MCP/MCP服务器使用说明.md create mode 100644 MCP/README.md create mode 100644 MCP/mcp.json.example create mode 100644 MCP/moncter-mcp-server/.gitignore create mode 100644 MCP/moncter-mcp-server/MCP接口对比分析.md create mode 100644 MCP/moncter-mcp-server/MCP服务器同步更新说明.md create mode 100644 MCP/moncter-mcp-server/MCP服务器更新说明.md create mode 100644 MCP/moncter-mcp-server/README.md create mode 100644 MCP/moncter-mcp-server/install.bat create mode 100644 MCP/moncter-mcp-server/install.sh create mode 100644 MCP/moncter-mcp-server/package-lock.json create mode 100644 MCP/moncter-mcp-server/package.json create mode 100644 MCP/moncter-mcp-server/src/index.ts create mode 100644 MCP/moncter-mcp-server/tsconfig.json create mode 100644 MCP/实现总结.md create mode 100644 MCP/快速开始.md delete mode 100644 README.en.md create mode 100644 TaskShow/.editorconfig create mode 100644 TaskShow/.gitignore create mode 100644 TaskShow/README.md create mode 100644 TaskShow/USAGE.md create mode 100644 TaskShow/index.html create mode 100644 TaskShow/package-lock.json create mode 100644 TaskShow/package.json create mode 100644 TaskShow/pnpm-lock.yaml create mode 100644 TaskShow/tsconfig.json create mode 100644 TaskShow/tsconfig.node.json create mode 100644 TaskShow/v0.39.1.tar.gz create mode 100644 TaskShow/vite.config.ts create mode 100644 app/command/BatchUpdateTags.php create mode 100644 app/command/InitTags.php create mode 100644 app/controller/ConsumptionController.php create mode 100644 app/controller/DataCollectionTaskController.php create mode 100644 app/controller/DataSourceController.php create mode 100644 app/controller/DatabaseSyncController.php create mode 100644 app/controller/PersonMergeController.php create mode 100644 app/controller/TagCohortController.php create mode 100644 app/controller/TagController.php create mode 100644 app/controller/TagDefinitionController.php create mode 100644 app/controller/TagTaskController.php create mode 100644 app/process/DataSyncScheduler.php create mode 100644 app/process/DataSyncWorker.php create mode 100644 app/process/TagCalculationWorker.php create mode 100644 app/repository/ConsumptionRecordRepository.php create mode 100644 app/repository/DataCollectionTaskRepository.php create mode 100644 app/repository/DataSourceRepository.php create mode 100644 app/repository/StoreRepository.php create mode 100644 app/repository/TagCohortRepository.php create mode 100644 app/repository/TagDefinitionRepository.php create mode 100644 app/repository/TagHistoryRepository.php create mode 100644 app/repository/TagTaskExecutionRepository.php create mode 100644 app/repository/TagTaskRepository.php create mode 100644 app/repository/UserPhoneRelationRepository.php create mode 100644 app/repository/UserProfileRepository.php create mode 100644 app/repository/UserTagRepository.php create mode 100644 app/service/ConsumptionService.php create mode 100644 app/service/DataCollection/Handler/BaseCollectionHandler.php create mode 100644 app/service/DataCollection/Handler/ConsumptionCollectionHandler.php create mode 100644 app/service/DataCollection/Handler/DatabaseSyncHandler.php create mode 100644 app/service/DataCollection/Handler/GenericCollectionHandler.php create mode 100644 app/service/DataCollection/Handler/TagTaskHandler.php create mode 100644 app/service/DataCollection/Handler/Trait/DataCollectionHelperTrait.php create mode 100644 app/service/DataCollectionTaskService.php create mode 100644 app/service/DataSource/Adapter/MongoDBAdapter.php create mode 100644 app/service/DataSource/Adapter/MySQLAdapter.php create mode 100644 app/service/DataSource/DataSourceAdapterFactory.php create mode 100644 app/service/DataSource/DataSourceAdapterInterface.php create mode 100644 app/service/DataSource/PollingStrategyFactory.php create mode 100644 app/service/DataSource/PollingStrategyInterface.php create mode 100644 app/service/DataSource/Strategy/DefaultConsumptionStrategy.php create mode 100644 app/service/DataSource/Strategy/MongoDBConsumptionStrategy.php create mode 100644 app/service/DataSourceService.php create mode 100644 app/service/DataSyncService.php create mode 100644 app/service/DatabaseSyncService.php create mode 100644 app/service/IdentifierService.php create mode 100644 app/service/PersonMergeService.php create mode 100644 app/service/StoreService.php create mode 100644 app/service/TagInitService.php create mode 100644 app/service/TagRuleEngine/SimpleRuleEngine.php create mode 100644 app/service/TagService.php create mode 100644 app/service/TagTaskExecutor.php create mode 100644 app/service/TagTaskService.php create mode 100644 app/service/UserPhoneService.php create mode 100644 app/service/UserService.php create mode 100644 app/utils/ApiResponseHelper.php create mode 100644 app/utils/DataMaskingHelper.php create mode 100644 app/utils/EncryptionHelper.php create mode 100644 app/utils/IdCardHelper.php create mode 100644 app/utils/LogMaskingProcessor.php create mode 100644 app/utils/LoggerHelper.php create mode 100644 app/utils/MongoDBHelper.php create mode 100644 app/utils/QueueService.php create mode 100644 app/utils/RedisHelper.php create mode 100644 config/data_collection_tasks.php create mode 100644 config/data_sources.php create mode 100644 config/encryption.php create mode 100644 config/queue.php create mode 100644 public/database-sync-dashboard.html create mode 100644 support/bootstrap/MongoDB.php diff --git a/MCP/MCP服务器使用说明.md b/MCP/MCP服务器使用说明.md new file mode 100644 index 0000000..1dd2aa4 --- /dev/null +++ b/MCP/MCP服务器使用说明.md @@ -0,0 +1,233 @@ +# Moncter MCP 服务器使用说明 + +## 概述 + +Moncter MCP Server 是一个基于 Model Context Protocol (MCP) 的服务器,允许通过 MCP 协议来管理 Moncter 系统的数据采集任务和标签任务。 + +## 安装步骤 + +### 1. 安装依赖 + +```bash +cd MCP/moncter-mcp-server +npm install +``` + +### 2. 编译 TypeScript + +```bash +npm run build +``` + +### 3. 配置 MCP + +编辑 `MCP/mcp.json` 文件,确保 Moncter MCP 服务器配置正确: + +```json +{ + "mcpServers": { + "Moncter": { + "command": "node", + "args": ["./MCP/moncter-mcp-server/dist/index.js"], + "cwd": "E:/Cunkebao/Cunkebao02/Moncter", + "env": { + "MONCTER_API_URL": "http://127.0.0.1:8787" + } + } + } +} +``` + +**注意**:`cwd` 路径需要根据实际项目路径修改。 + +## 可用的 MCP 工具 + +### 1. create_data_collection_task + +创建数据采集任务。 + +**参数**: +- `name` (string, 必需): 任务名称 +- `description` (string, 可选): 任务描述 +- `data_source_id` (string, 必需): 数据源ID +- `database` (string, 必需): 数据库名称 +- `collection` (string, 可选): 集合名称(单集合模式) +- `collections` (array, 可选): 集合列表(多集合模式) +- `mode` (string, 必需): 采集模式(batch/realtime) +- `field_mappings` (array, 可选): 字段映射配置 +- `schedule` (object, 可选): 调度配置 + +**示例**: +```json +{ + "name": "create_data_collection_task", + "arguments": { + "name": "订单数据采集", + "data_source_id": "data_source_id_123", + "database": "KR_商城", + "collection": "21年贝蒂喜订单整合", + "mode": "realtime" + } +} +``` + +### 2. create_tag_task + +创建标签计算任务。 + +**参数**: +- `name` (string, 必需): 任务名称 +- `task_type` (string, 必需): 任务类型(full/incremental/specific) +- `target_tag_ids` (array, 必需): 要计算的标签ID列表 +- `user_scope` (object, 可选): 用户范围配置 +- `schedule` (object, 可选): 调度配置 +- `config` (object, 可选): 高级配置 + +**示例**: +```json +{ + "name": "create_tag_task", + "arguments": { + "name": "高价值用户标签计算", + "task_type": "full", + "target_tag_ids": ["tag_id_1", "tag_id_2"], + "user_scope": { + "type": "all" + } + } +} +``` + +### 3. list_data_collection_tasks + +获取数据采集任务列表。 + +**参数**: +- `page` (number, 可选): 页码 +- `page_size` (number, 可选): 每页数量 + +### 4. list_tag_tasks + +获取标签任务列表。 + +**参数**: +- `page` (number, 可选): 页码 +- `page_size` (number, 可选): 每页数量 + +### 5. get_data_sources + +获取数据源列表。 + +**参数**: +- `type` (string, 可选): 数据源类型筛选 +- `status` (number, 可选): 状态筛选(1=启用,0=禁用) + +### 6. get_tag_definitions + +获取标签定义列表。 + +**参数**: +- `status` (number, 可选): 状态筛选(1=启用,0=禁用) + +### 7. start_data_collection_task + +启动数据采集任务。 + +**参数**: +- `task_id` (string, 必需): 任务ID + +### 8. start_tag_task + +启动标签任务。 + +**参数**: +- `task_id` (string, 必需): 任务ID + +## 环境变量 + +- `MONCTER_API_URL`: 后端API基础URL(默认: http://127.0.0.1:8787) + +## 使用场景 + +### 场景1:通过 AI 助手创建数据采集任务 + +你可以通过支持 MCP 的 AI 助手(如 Claude Desktop)来创建任务: + +1. 告诉 AI:"创建一个实时监听的数据采集任务,从数据源 X 的数据库 Y 的集合 Z 采集数据" +2. AI 会调用 `create_data_collection_task` 工具 +3. 任务创建成功后,AI 会告诉你任务ID和状态 + +### 场景2:批量创建标签任务 + +通过 MCP 工具批量创建多个标签计算任务: + +1. 列出所有标签定义:`get_tag_definitions` +2. 为每个标签创建计算任务:`create_tag_task` +3. 启动所有任务:`start_tag_task` + +### 场景3:任务管理 + +通过 MCP 工具查询和管理任务: + +1. 列出所有任务:`list_data_collection_tasks` / `list_tag_tasks` +2. 查看任务详情和状态 +3. 启动/暂停/停止任务 + +## 开发调试 + +### 开发模式 + +```bash +cd MCP/moncter-mcp-server +npm run dev +``` + +### 测试 MCP 服务器 + +可以使用 MCP Inspector 或其他 MCP 客户端工具来测试服务器: + +```bash +# 如果安装了 @modelcontextprotocol/inspector +npx @modelcontextprotocol/inspector node dist/index.js +``` + +## 故障排除 + +### 问题1:服务器无法启动 + +- 检查 Node.js 版本(需要 >= 18) +- 检查是否已安装依赖:`npm install` +- 检查是否已编译:`npm run build` + +### 问题2:无法连接到后端API + +- 检查 `MONCTER_API_URL` 环境变量是否正确 +- 检查后端服务是否运行在指定端口 +- 检查防火墙和网络连接 + +### 问题3:工具调用失败 + +- 检查后端API接口是否正常 +- 检查参数是否正确 +- 查看服务器日志输出 + +## 扩展开发 + +要添加新的 MCP 工具: + +1. 在 `src/index.ts` 的 `ListToolsRequestSchema` handler 中添加新工具定义 +2. 在 `CallToolRequestSchema` handler 中添加工具处理逻辑 +3. 重新编译:`npm run build` +4. 重启 MCP 服务器 + +## 注意事项 + +1. **API URL 配置**:确保 `MONCTER_API_URL` 指向正确的后端服务地址 +2. **路径配置**:`mcp.json` 中的 `cwd` 和 `args` 路径需要根据实际项目路径调整 +3. **权限**:MCP 工具调用会直接操作后端API,请确保权限控制 +4. **错误处理**:工具调用失败时会返回错误信息,请检查返回内容 + +--- + +**更新时间**:2025-01-24 + diff --git a/MCP/README.md b/MCP/README.md new file mode 100644 index 0000000..072190b --- /dev/null +++ b/MCP/README.md @@ -0,0 +1,70 @@ +# Moncter MCP 集成 + +这个目录包含 Moncter 系统的 MCP (Model Context Protocol) 服务器实现。 + +## 目录结构 + +``` +MCP/ +├── mcp.json # MCP 服务器配置文件(需要配置路径) +├── mcp.json.example # MCP 配置文件示例 +├── moncter-mcp-server/ # Moncter MCP 服务器源代码 +│ ├── src/ +│ │ └── index.ts # 服务器主文件 +│ ├── package.json # Node.js 依赖配置 +│ ├── tsconfig.json # TypeScript 配置 +│ ├── install.sh # Linux/Mac 安装脚本 +│ ├── install.bat # Windows 安装脚本 +│ └── README.md # 服务器详细文档 +├── 快速开始.md # 快速开始指南 +└── MCP服务器使用说明.md # 详细使用说明 +``` + +## 快速开始 + +1. **安装 MCP Server** + + ```bash + cd MCP/moncter-mcp-server + npm install + npm run build + ``` + +2. **配置 MCP 客户端** + + 编辑 `MCP/mcp.json`,将 `YOUR_PROJECT_PATH` 替换为实际的项目路径。 + +3. **启动后端服务** + + ```bash + php start.php start + ``` + +4. **使用 MCP 工具** + + 在支持 MCP 的 AI 客户端(如 Claude Desktop)中使用 Moncter MCP 服务器提供的工具。 + +## 可用的 MCP 工具 + +- `create_data_collection_task` - 创建数据采集任务 +- `create_tag_task` - 创建标签计算任务 +- `list_data_collection_tasks` - 获取数据采集任务列表 +- `list_tag_tasks` - 获取标签任务列表 +- `get_data_sources` - 获取数据源列表 +- `get_tag_definitions` - 获取标签定义列表 +- `start_data_collection_task` - 启动数据采集任务 +- `start_tag_task` - 启动标签任务 + +## 文档 + +- [快速开始指南](./快速开始.md) - 安装和配置步骤 +- [详细使用说明](./MCP服务器使用说明.md) - 完整的工具说明和使用示例 +- [服务器 README](./moncter-mcp-server/README.md) - 服务器开发文档 + +## 注意事项 + +1. 确保 Node.js 版本 >= 18 +2. 确保后端服务运行在配置的端口(默认 8787) +3. 配置文件中的路径需要根据实际情况修改 +4. MCP 工具调用会直接操作后端API,请确保权限控制 + diff --git a/MCP/mcp.json.example b/MCP/mcp.json.example new file mode 100644 index 0000000..ac79f5a --- /dev/null +++ b/MCP/mcp.json.example @@ -0,0 +1,27 @@ +{ + "mcpServers": { + "MongoDB_ckb": { + "command": "npx", + "args": ["-y", "mongodb-mcp-server@1.2.0", "--readOnly"], + "env": { + "MDB_MCP_CONNECTION_STRING": "mongodb://ckb:123456@192.168.1.106:27017/ckb" + } + }, + "MongoDB_KR": { + "command": "npx", + "args": ["-y", "mongodb-mcp-server@1.2.0", "--readOnly"], + "env": { + "MDB_MCP_CONNECTION_STRING": "mongodb://admin:key123456@192.168.2.16:27017/admin" + } + }, + "Moncter": { + "command": "node", + "args": ["./MCP/moncter-mcp-server/dist/index.js"], + "cwd": "YOUR_PROJECT_PATH", + "env": { + "MONCTER_API_URL": "http://127.0.0.1:8787" + } + } + } +} + diff --git a/MCP/moncter-mcp-server/.gitignore b/MCP/moncter-mcp-server/.gitignore new file mode 100644 index 0000000..b365c4e --- /dev/null +++ b/MCP/moncter-mcp-server/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +*.log +.DS_Store +.env +*.tsbuildinfo + diff --git a/MCP/moncter-mcp-server/MCP接口对比分析.md b/MCP/moncter-mcp-server/MCP接口对比分析.md new file mode 100644 index 0000000..2485213 --- /dev/null +++ b/MCP/moncter-mcp-server/MCP接口对比分析.md @@ -0,0 +1,194 @@ +# MCP服务器接口对比分析 + +## 问题分析 + +对比标签引擎的MCP服务器和实际的采集任务接口,发现以下差异: + +--- + +## 一、后端接口要求(DataCollectionTaskController) + +### 必填字段(从Controller验证逻辑看): +```php +$requiredFields = ['name', 'data_source_id', 'database', 'target_data_source_id', 'target_database', 'target_collection']; +``` + +### 可选但支持的字段: +- `target_type` - 目标类型(consumption_record 或 generic) +- `mode` - 采集模式(batch 或 realtime) +- `collection` - 单集合模式 +- `collections` - 多集合模式(与collection二选一) +- `multi_collection` - 是否多集合模式 +- `field_mappings` - 字段映射(单集合模式) +- `collection_field_mappings` - 字段映射(多集合模式) +- `lookups` - 连表查询配置(单集合模式) +- `collection_lookups` - 连表查询配置(多集合模式) +- `filter_conditions` - 过滤条件 +- `schedule` - 调度配置 +- `description` - 任务描述 + +--- + +## 二、前端TaskForm实际使用的字段 + +根据 `TaskShow/src/views/DataCollection/TaskForm.vue`,前端表单包含: + +```typescript +{ + name: string + description: string + data_source_id: string + database: string + collection?: string // 单集合模式 + collections?: string[] // 多集合模式 + multi_collection: boolean + target_type: string // 'consumption_record' | 'generic' + mode: 'batch' | 'realtime' + field_mappings: FieldMapping[] + collection_field_mappings?: Record + lookups?: LookupConfig[] + collection_lookups?: Record + filter_conditions?: FilterCondition[] + schedule: { + enabled: boolean + cron?: string + } +} +``` + +**注意**:前端**没有** `target_data_source_id`, `target_database`, `target_collection` 字段! + +--- + +## 三、MCP服务器当前支持的字段 + +根据 `MCP/moncter-mcp-server/src/index.ts`,当前支持: + +```typescript +{ + name: string + description: string + data_source_id: string + database: string + collection?: string + collections?: string[] + mode: 'batch' | 'realtime' + field_mappings: FieldMapping[] + schedule: { + enabled: boolean + cron?: string + } +} +``` + +--- + +## 四、字段差异对比 + +### ❌ MCP服务器缺少的字段: + +1. **`target_type`** - 目标类型(重要!) + - 前端使用:`'consumption_record'` 或 `'generic'` + - 用途:决定使用哪个Handler处理数据 + - 优先级:**高** + +2. **`multi_collection`** - 多集合模式标识 + - 前端使用:`boolean` + - 用途:区分单集合和多集合模式 + - 优先级:**中** + +3. **`filter_conditions`** - 过滤条件 + - 前端使用:`FilterCondition[]` + - 用途:数据采集的过滤条件 + - 优先级:**中** + +4. **`lookups`** - 连表查询配置(单集合模式) + - 前端使用:`LookupConfig[]` + - 用途:MongoDB $lookup 连表查询 + - 优先级:**低** + +5. **`collection_lookups`** - 连表查询配置(多集合模式) + - 前端使用:`Record` + - 用途:多集合模式下的连表查询 + - 优先级:**低** + +6. **`collection_field_mappings`** - 字段映射(多集合模式) + - 前端使用:`Record` + - 用途:多集合模式下每个集合的字段映射 + - 优先级:**中** + +### ⚠️ 后端Controller要求的字段(但前端和Service都没使用): + +1. **`target_data_source_id`** - 目标数据源ID +2. **`target_database`** - 目标数据库 +3. **`target_collection`** - 目标集合 + +**分析**: +- Controller的验证逻辑要求这些字段必填 +- 但Service的`createTask`方法并没有使用这些字段 +- 前端TaskForm也没有这些字段 +- 对于`consumption_record`类型,Handler会自动处理存储,不需要指定目标 +- 对于`generic`类型,可能需要这些字段 + +**结论**:这些字段可能是历史遗留,或者仅对`generic`类型必需,对`consumption_record`类型不是必需的。需要确认后端逻辑。 + +--- + +## 五、建议的修复方案 + +### 方案1:更新MCP服务器,添加缺失字段 + +**优先添加的字段**: +1. ✅ `target_type` - **必需** +2. ✅ `multi_collection` - **推荐** +3. ✅ `filter_conditions` - **推荐** +4. ✅ `collection_field_mappings` - **推荐**(支持多集合模式) +5. ⚠️ `lookups` - 可选 +6. ⚠️ `collection_lookups` - 可选 + +**暂不处理**: +- `target_data_source_id`, `target_database`, `target_collection` - 需要先确认后端逻辑 + +### 方案2:确认后端接口的实际要求 + +需要确认: +1. `target_data_source_id`, `target_database`, `target_collection` 是否真的必需? +2. 对于`consumption_record`类型,这些字段是否可选? +3. 如果必需,MCP服务器需要添加这些字段 + +--- + +## 六、当前状态总结 + +### ✅ MCP服务器已支持的字段: +- name +- description +- data_source_id +- database +- collection / collections +- mode +- field_mappings +- schedule + +### ❌ MCP服务器缺少的字段(按优先级): +1. **target_type** - ⚠️ 重要!决定Handler类型 +2. **multi_collection** - 区分单/多集合模式 +3. **filter_conditions** - 数据过滤条件 +4. **collection_field_mappings** - 多集合模式的字段映射 +5. **lookups** - 连表查询(可选) +6. **collection_lookups** - 多集合连表查询(可选) + +### ⚠️ 需要确认的字段: +- target_data_source_id +- target_database +- target_collection + +--- + +## 七、下一步行动 + +1. ✅ 对比分析完成 +2. ⏳ 更新MCP服务器,添加缺失字段 +3. ⏳ 确认后端接口对target相关字段的要求 +4. ⏳ 测试更新后的MCP服务器 + diff --git a/MCP/moncter-mcp-server/MCP服务器同步更新说明.md b/MCP/moncter-mcp-server/MCP服务器同步更新说明.md new file mode 100644 index 0000000..d144db1 --- /dev/null +++ b/MCP/moncter-mcp-server/MCP服务器同步更新说明.md @@ -0,0 +1,342 @@ +# MCP服务器同步更新说明 + +## 更新日期 +2025年12月 + +## 更新背景 + +根据最新的 `TaskForm.vue` 界面变更,MCP服务器需要同步更新以匹配最新的界面逻辑。 + +--- + +## 主要变更 + +### ✅ 移除的字段 + +根据最新的TaskForm.vue,以下字段已经从界面中移除,MCP服务器也已移除: + +1. **`target_data_source_id`** - 目标数据源ID +2. **`target_database`** - 目标数据库 +3. **`target_collection`** - 目标集合 + +**原因**: +- 对于 `consumption_record` 类型,Handler会自动处理存储到标签引擎数据库,不需要指定目标 +- 对于 `generic` 类型,如果需要指定目标,应该在Handler配置或业务逻辑中处理 +- 界面已经简化,不再要求用户配置这些字段 + +### ✅ 保留和优化的字段 + +#### 核心字段(必填) + +1. **`name`** - 任务名称 +2. **`data_source_id`** - 源数据源ID +3. **`database`** - 源数据库名称 +4. **`mode`** - 采集模式(`batch` 或 `realtime`) +5. **`target_type`** - 目标类型(`consumption_record` 或 `generic`) + +#### 集合配置字段 + +6. **`collection`** - 源集合名称(单集合模式) +7. **`collections`** - 源集合列表(多集合模式) +8. **`multi_collection`** - 是否启用多集合模式 + +**说明**:`collection` 和 `collections` 二选一,由 `multi_collection` 字段决定使用哪个。 + +#### 字段映射字段 + +9. **`field_mappings`** - 字段映射配置(单集合模式) + - 格式:`[{ source_field, target_field, transform? }]` + - 转换函数:`parse_amount`, `parse_datetime`, `parse_phone` + +10. **`collection_field_mappings`** - 字段映射配置(多集合模式) + - 格式:`{ "collection_name": [FieldMapping] }` + - 每个集合可配置独立的字段映射 + +#### 查询配置字段 + +11. **`lookups`** - MongoDB $lookup连表查询配置(单集合模式) + - 格式:`[{ from, local_field, foreign_field, as, unwrap?, preserve_null? }]` + +12. **`collection_lookups`** - MongoDB $lookup连表查询配置(多集合模式) + - 格式:`{ "collection_name": [LookupConfig] }` + +13. **`filter_conditions`** - 过滤条件 + - 格式:`[{ field, operator, value }]` + - 运算符:`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin` + +#### 调度配置字段 + +14. **`schedule`** - 调度配置(批量模式使用) + - `enabled`: 是否启用调度 + - `cron`: Cron表达式 + +--- + +## 字段使用指南 + +### 1. 消费记录采集任务(consumption_record) + +**适用场景**:采集订单、交易等消费记录数据 + +**特点**: +- Handler会自动转换数据格式 +- 通过手机号/身份证自动解析用户ID +- 自动时间分片存储(按月分表) +- 存储到标签引擎数据库 + +**示例**: +```json +{ + "name": "订单数据采集", + "description": "从KR商城采集订单数据", + "data_source_id": "source_123", + "database": "KR_商城", + "collection": "21年贝蒂喜订单整合", + "multi_collection": false, + "target_type": "consumption_record", + "mode": "batch", + "field_mappings": [ + { + "source_field": "联系手机", + "target_field": "phone_number", + "transform": "parse_phone" + }, + { + "source_field": "买家实际支付金额", + "target_field": "actual_amount", + "transform": "parse_amount" + }, + { + "source_field": "订单付款时间", + "target_field": "consume_time", + "transform": "parse_datetime" + }, + { + "source_field": "店铺名称", + "target_field": "store_name" + } + ], + "filter_conditions": [ + { + "field": "买家实际支付金额", + "operator": "ne", + "value": "0" + }, + { + "field": "订单付款时间", + "operator": "ne", + "value": null + } + ], + "schedule": { + "enabled": true, + "cron": "0 2 * * *" + } +} +``` + +### 2. 通用集合采集任务(generic) + +**适用场景**:采集任意数据并存储到指定集合 + +**特点**: +- 需要自定义字段映射 +- 需要指定目标存储(在Handler配置中) + +**示例**: +```json +{ + "name": "通用数据采集", + "description": "采集用户数据", + "data_source_id": "source_123", + "database": "user_db", + "collection": "users", + "multi_collection": false, + "target_type": "generic", + "mode": "realtime", + "field_mappings": [ + { + "source_field": "user_name", + "target_field": "name" + }, + { + "source_field": "user_email", + "target_field": "email" + } + ], + "schedule": { + "enabled": false + } +} +``` + +### 3. 多集合模式 + +**适用场景**:同时从多个集合采集数据 + +**示例**: +```json +{ + "name": "多集合数据采集", + "data_source_id": "source_123", + "database": "multi_db", + "multi_collection": true, + "collections": ["collection1", "collection2"], + "target_type": "generic", + "mode": "batch", + "collection_field_mappings": { + "collection1": [ + { + "source_field": "field1", + "target_field": "target1" + } + ], + "collection2": [ + { + "source_field": "field2", + "target_field": "target2" + } + ] + }, + "collection_lookups": { + "collection1": [ + { + "from": "related_collection", + "local_field": "related_id", + "foreign_field": "_id", + "as": "related_data", + "unwrap": false, + "preserve_null": true + } + ] + }, + "schedule": { + "enabled": true, + "cron": "0 3 * * *" + } +} +``` + +### 4. 连表查询配置 + +**适用场景**:需要从其他集合关联数据 + +**示例**: +```json +{ + "lookups": [ + { + "from": "user_info", + "local_field": "user_id", + "foreign_field": "_id", + "as": "user_info", + "unwrap": true, + "preserve_null": false + } + ] +} +``` + +**说明**: +- `unwrap: true` - 解构后可直接使用 `user_info.mobile` +- `unwrap: false` - 返回数组 `user_info[0].mobile` + +--- + +## 与界面的对应关系 + +| MCP字段 | TaskForm字段 | 界面位置 | 说明 | +|---------|-------------|---------|------| +| name | form.name | 步骤1:基本信息 | 任务名称 | +| description | form.description | 步骤1:基本信息 | 任务描述 | +| mode | form.mode | 步骤1:基本信息 | 采集模式 | +| target_type | form.target_type | 步骤2:Handler配置 | 数据处理方式 | +| data_source_id | form.data_source_id | 步骤3:源数据配置 | 数据源 | +| database | form.database | 步骤3:源数据配置 | 数据库 | +| collection | form.collection | 步骤3:源数据配置 | 集合(单集合) | +| collections | form.collections | 步骤3:源数据配置 | 集合列表(多集合) | +| multi_collection | form.multi_collection | 步骤3:源数据配置 | 多集合模式开关 | +| lookups | form.lookups | 步骤3:连表查询 | 连表查询配置 | +| filter_conditions | form.filter_conditions | 步骤3:过滤条件 | 过滤条件 | +| field_mappings | form.field_mappings | 步骤4:字段映射 | 字段映射(单集合) | +| collection_field_mappings | form.collection_field_mappings | 步骤4:字段映射 | 字段映射(多集合) | +| schedule | form.schedule | 步骤5:调度配置 | 调度配置 | + +--- + +## 验证清单 + +✅ MCP服务器已更新,完全匹配最新的TaskForm.vue界面逻辑: + +- [x] 移除了 `target_data_source_id`, `target_database`, `target_collection` 字段 +- [x] 保留了所有界面使用的字段 +- [x] 更新了字段描述,使其更清晰准确 +- [x] 明确了字段的使用场景和条件要求 +- [x] 支持单集合和多集合模式 +- [x] 支持连表查询和过滤条件 +- [x] 支持字段映射和转换函数 + +--- + +## 注意事项 + +1. **target_type是必填的**:必须明确指定是 `consumption_record` 还是 `generic` +2. **collection和collections二选一**:根据 `multi_collection` 决定使用哪个 +3. **字段映射方式**: + - 单集合模式使用 `field_mappings` + - 多集合模式使用 `collection_field_mappings` +4. **连表查询方式**: + - 单集合模式使用 `lookups` + - 多集合模式使用 `collection_lookups` +5. **调度配置**:仅在 `mode=batch` 时使用 + +--- + +## 测试建议 + +1. **测试消费记录采集任务创建** + ```json + { + "name": "测试任务", + "data_source_id": "test_source", + "database": "test_db", + "collection": "test_collection", + "target_type": "consumption_record", + "mode": "batch", + "field_mappings": [...], + "schedule": {"enabled": true, "cron": "0 2 * * *"} + } + ``` + +2. **测试多集合模式** + ```json + { + "name": "多集合测试", + "data_source_id": "test_source", + "database": "test_db", + "multi_collection": true, + "collections": ["coll1", "coll2"], + "target_type": "generic", + "mode": "batch", + "collection_field_mappings": {...} + } + ``` + +3. **测试连表查询** + ```json + { + "lookups": [{ + "from": "related", + "local_field": "id", + "foreign_field": "_id", + "as": "related_data" + }] + } + ``` + +--- + +## 总结 + +MCP服务器已成功同步更新,完全匹配最新的TaskForm.vue界面逻辑。所有字段定义、使用方式和验证规则都与界面保持一致。 + diff --git a/MCP/moncter-mcp-server/MCP服务器更新说明.md b/MCP/moncter-mcp-server/MCP服务器更新说明.md new file mode 100644 index 0000000..8c63615 --- /dev/null +++ b/MCP/moncter-mcp-server/MCP服务器更新说明.md @@ -0,0 +1,246 @@ +# MCP服务器更新说明 + +## 更新日期 +2025年12月 + +## 更新内容 + +### ✅ 已添加的字段 + +更新了 `create_data_collection_task` 工具,添加了以下缺失的字段: + +1. **`target_type`** ⭐ **重要** + - 类型:`'consumption_record' | 'generic'` + - 必填:是 + - 说明:决定使用哪个Handler处理数据 + - 用途: + - `consumption_record`: 使用ConsumptionCollectionHandler,自动处理消费记录格式转换和存储 + - `generic`: 使用GenericCollectionHandler,支持自定义字段映射和目标存储 + +2. **`multi_collection`** + - 类型:`boolean` + - 必填:否 + - 说明:是否启用多集合模式 + +3. **`target_data_source_id`** + - 类型:`string` + - 必填:否(但后端Controller验证要求必填,见注意事项) + - 说明:目标数据源ID(通用Handler需要) + +4. **`target_database`** + - 类型:`string` + - 必填:否(但后端Controller验证要求必填,见注意事项) + - 说明:目标数据库(通用Handler需要) + +5. **`target_collection`** + - 类型:`string` + - 必填:否(但后端Controller验证要求必填,见注意事项) + - 说明:目标集合(通用Handler需要) + +6. **`collection_field_mappings`** + - 类型:`object` + - 必填:否 + - 说明:多集合模式下的字段映射,格式:`{ "collection_name": [FieldMapping] }` + +7. **`lookups`** + - 类型:`array` + - 必填:否 + - 说明:单集合模式下的MongoDB $lookup连表查询配置 + - 结构: + ```typescript + { + from: string, // 关联集合名 + local_field: string, // 主集合字段 + foreign_field: string, // 关联集合字段 + as: string, // 结果字段名 + unwrap?: boolean, // 是否解构 + preserve_null?: boolean // 是否保留空值 + } + ``` + +8. **`collection_lookups`** + - 类型:`object` + - 必填:否 + - 说明:多集合模式下的连表查询配置,格式:`{ "collection_name": [LookupConfig] }` + +9. **`filter_conditions`** + - 类型:`array` + - 必填:否 + - 说明:数据采集的过滤条件 + - 结构: + ```typescript + { + field: string, + operator: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin', + value: any + } + ``` + +### 📝 更新的字段说明 + +- **`field_mappings`**: 添加了更详细的说明,明确是单集合模式的字段映射 + +### ✅ 更新的必填字段 + +- 新增 `target_type` 为必填字段(这是最重要的字段,决定Handler类型) + +--- + +## ⚠️ 注意事项 + +### 1. 后端Controller验证问题 + +**问题**: +后端 `DataCollectionTaskController::create()` 方法要求以下字段必填: +- `target_data_source_id` +- `target_database` +- `target_collection` + +**但实际情况**: +- 前端 `TaskForm.vue` 没有这些字段 +- Service的 `createTask()` 方法也没有使用这些字段 +- 对于 `consumption_record` 类型,Handler会自动处理存储,不需要指定目标 + +**可能的原因**: +1. Controller的验证逻辑过于严格 +2. 这些字段仅对 `generic` 类型必需 +3. 后端代码不一致(Controller和Service不同步) + +**建议**: +1. 如果使用 `consumption_record` 类型,可能需要传递空值或默认值 +2. 如果使用 `generic` 类型,必须提供这些字段 +3. 建议后端修改验证逻辑,根据 `target_type` 动态验证必填字段 + +### 2. 字段使用建议 + +**对于 consumption_record 类型**: +```json +{ + "name": "订单采集任务", + "data_source_id": "source_123", + "database": "KR_商城", + "collection": "21年贝蒂喜订单整合", + "target_type": "consumption_record", + "mode": "batch", + "field_mappings": [ + { + "source_field": "联系手机", + "target_field": "phone_number", + "transform": "parse_phone" + }, + { + "source_field": "买家实际支付金额", + "target_field": "actual_amount", + "transform": "parse_amount" + } + ], + "filter_conditions": [ + { + "field": "买家实际支付金额", + "operator": "ne", + "value": "0" + } + ], + "schedule": { + "enabled": true, + "cron": "0 2 * * *" + } +} +``` + +**对于 generic 类型**: +```json +{ + "name": "通用数据采集", + "data_source_id": "source_123", + "database": "KR_商城", + "collection": "some_collection", + "target_type": "generic", + "target_data_source_id": "target_source_123", + "target_database": "target_db", + "target_collection": "target_collection", + "mode": "batch", + "field_mappings": [ + { + "source_field": "source_field1", + "target_field": "target_field1" + } + ], + "schedule": { + "enabled": false + } +} +``` + +--- + +## 📊 字段对比表 + +| 字段 | MCP服务器(更新前) | MCP服务器(更新后) | 前端 | 后端Controller | 优先级 | +|------|-------------------|-------------------|------|---------------|--------| +| name | ✅ | ✅ | ✅ | ✅ 必填 | 高 | +| data_source_id | ✅ | ✅ | ✅ | ✅ 必填 | 高 | +| database | ✅ | ✅ | ✅ | ✅ 必填 | 高 | +| collection/collections | ✅ | ✅ | ✅ | ✅ 必填 | 高 | +| mode | ✅ | ✅ | ✅ | ✅ | 高 | +| field_mappings | ✅ | ✅ | ✅ | ✅ | 中 | +| schedule | ✅ | ✅ | ✅ | ✅ | 中 | +| target_type | ❌ | ✅ **新增** | ✅ | ✅ | **高** ⭐ | +| multi_collection | ❌ | ✅ **新增** | ✅ | ✅ | 中 | +| filter_conditions | ❌ | ✅ **新增** | ✅ | ✅ | 中 | +| collection_field_mappings | ❌ | ✅ **新增** | ✅ | ✅ | 中 | +| lookups | ❌ | ✅ **新增** | ✅ | ✅ | 低 | +| collection_lookups | ❌ | ✅ **新增** | ✅ | ✅ | 低 | +| target_data_source_id | ❌ | ✅ **新增** | ❌ | ✅ 必填 | ⚠️ | +| target_database | ❌ | ✅ **新增** | ❌ | ✅ 必填 | ⚠️ | +| target_collection | ❌ | ✅ **新增** | ❌ | ✅ 必填 | ⚠️ | + +--- + +## ✅ 更新后的状态 + +### 完全支持的字段: +- ✅ 基本字段(name, description, data_source_id, database, collection/collections) +- ✅ 模式配置(mode, multi_collection) +- ✅ 目标配置(target_type, target_data_source_id, target_database, target_collection) +- ✅ 字段映射(field_mappings, collection_field_mappings) +- ✅ 查询配置(lookups, collection_lookups, filter_conditions) +- ✅ 调度配置(schedule) + +### ⚠️ 需要注意的问题: +- 后端Controller要求 `target_data_source_id`, `target_database`, `target_collection` 必填,但前端和Service都没有使用 +- 建议在使用MCP创建任务时,对于 `consumption_record` 类型,可能需要传递这些字段的空值或默认值,或者后端需要修改验证逻辑 + +--- + +## 🔄 下一步建议 + +1. **后端验证逻辑优化**: + - 根据 `target_type` 动态验证必填字段 + - 对于 `consumption_record` 类型,不需要 `target_*` 字段 + - 对于 `generic` 类型,需要 `target_*` 字段 + +2. **测试验证**: + - 测试使用MCP创建 `consumption_record` 类型的任务 + - 测试使用MCP创建 `generic` 类型的任务 + - 验证所有新增字段是否正确传递 + +3. **文档更新**: + - 更新MCP使用文档,说明不同 `target_type` 的字段要求 + - 添加使用示例 + +--- + +## 📝 总结 + +MCP服务器已经更新,**基本符合**当前的采集任务接口要求。主要添加了: + +1. ✅ **target_type** - 最重要的字段,决定Handler类型 +2. ✅ **multi_collection** - 支持多集合模式 +3. ✅ **filter_conditions** - 支持数据过滤 +4. ✅ **lookups/collection_lookups** - 支持连表查询 +5. ✅ **collection_field_mappings** - 支持多集合字段映射 +6. ✅ **target_* 字段** - 虽然前端没有,但后端Controller要求,已添加 + +**剩余问题**:后端Controller的验证逻辑可能与实际使用不一致,需要确认或调整。 + diff --git a/MCP/moncter-mcp-server/README.md b/MCP/moncter-mcp-server/README.md new file mode 100644 index 0000000..7e394f0 --- /dev/null +++ b/MCP/moncter-mcp-server/README.md @@ -0,0 +1,122 @@ +# Moncter MCP Server + +Moncter MCP Server 是一个 Model Context Protocol (MCP) 服务器,用于通过 MCP 协议管理 Moncter 系统的数据采集任务和标签任务。 + +## 功能 + +提供以下 MCP 工具: + +1. **create_data_collection_task** - 创建数据采集任务 +2. **create_tag_task** - 创建标签计算任务 +3. **list_data_collection_tasks** - 获取数据采集任务列表 +4. **list_tag_tasks** - 获取标签任务列表 +5. **get_data_sources** - 获取数据源列表 +6. **get_tag_definitions** - 获取标签定义列表 +7. **start_data_collection_task** - 启动数据采集任务 +8. **start_tag_task** - 启动标签任务 + +## 安装 + +```bash +cd MCP/moncter-mcp-server +npm install +npm run build +``` + +## 配置 + +在 `mcp.json` 中配置服务器: + +```json +{ + "mcpServers": { + "Moncter": { + "command": "node", + "args": ["E:/Cunkebao/Cunkebao02/Moncter/MCP/moncter-mcp-server/dist/index.js"], + "env": { + "MONCTER_API_URL": "http://127.0.0.1:8787" + } + } + } +} +``` + +或者使用 npm 方式(如果全局安装): + +```json +{ + "mcpServers": { + "Moncter": { + "command": "node", + "args": ["./MCP/moncter-mcp-server/dist/index.js"], + "cwd": "E:/Cunkebao/Cunkebao02/Moncter", + "env": { + "MONCTER_API_URL": "http://127.0.0.1:8787" + } + } + } +} +``` + +## 环境变量 + +- `MONCTER_API_URL`: 后端API基础URL(默认: http://127.0.0.1:8787) + +## 使用示例 + +### 创建数据采集任务 + +```json +{ + "name": "create_data_collection_task", + "arguments": { + "name": "订单数据采集", + "description": "从KR商城采集订单数据", + "data_source_id": "your_data_source_id", + "database": "KR_商城", + "collection": "21年贝蒂喜订单整合", + "mode": "realtime", + "field_mappings": [ + { + "source_field": "订单号", + "target_field": "order_no" + } + ] + } +} +``` + +### 创建标签任务 + +```json +{ + "name": "create_tag_task", + "arguments": { + "name": "高价值用户标签计算", + "description": "计算高价值用户标签", + "task_type": "full", + "target_tag_ids": ["tag_id_1", "tag_id_2"], + "user_scope": { + "type": "all" + }, + "schedule": { + "enabled": true, + "cron": "0 2 * * *" + } + } +} +``` + +## 开发 + +```bash +# 开发模式(使用 tsx) +npm run dev + +# 编译 +npm run build + +# 运行 +npm start +``` + diff --git a/MCP/moncter-mcp-server/install.bat b/MCP/moncter-mcp-server/install.bat new file mode 100644 index 0000000..5a07791 --- /dev/null +++ b/MCP/moncter-mcp-server/install.bat @@ -0,0 +1,39 @@ +@echo off +REM Moncter MCP Server 安装脚本 (Windows) + +echo 正在安装 Moncter MCP Server... + +REM 检查 Node.js +where node >nul 2>nul +if %ERRORLEVEL% NEQ 0 ( + echo 错误: 未找到 Node.js,请先安装 Node.js (^>= 18^) + exit /b 1 +) + +REM 安装依赖 +echo 安装依赖... +call npm install +if %ERRORLEVEL% NEQ 0 ( + echo 错误: npm install 失败 + exit /b 1 +) + +REM 编译 TypeScript +echo 编译 TypeScript... +call npm run build +if %ERRORLEVEL% NEQ 0 ( + echo 错误: 编译失败 + exit /b 1 +) + +echo. +echo ✅ Moncter MCP Server 安装成功! +echo. +echo 使用说明: +echo 1. 确保后端服务运行在 http://127.0.0.1:8787 +echo 2. 配置 MCP 客户端,添加 Moncter MCP 服务器 +echo 3. 查看 MCP/MCP服务器使用说明.md 了解详细用法 +echo. + +pause + diff --git a/MCP/moncter-mcp-server/install.sh b/MCP/moncter-mcp-server/install.sh new file mode 100644 index 0000000..17f9dab --- /dev/null +++ b/MCP/moncter-mcp-server/install.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# Moncter MCP Server 安装脚本 + +echo "正在安装 Moncter MCP Server..." + +# 检查 Node.js +if ! command -v node &> /dev/null; then + echo "错误: 未找到 Node.js,请先安装 Node.js (>= 18)" + exit 1 +fi + +NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1) +if [ "$NODE_VERSION" -lt 18 ]; then + echo "错误: Node.js 版本过低,需要 >= 18" + exit 1 +fi + +# 安装依赖 +echo "安装依赖..." +npm install + +# 编译 TypeScript +echo "编译 TypeScript..." +npm run build + +if [ $? -eq 0 ]; then + echo "✅ Moncter MCP Server 安装成功!" + echo "" + echo "使用说明:" + echo "1. 确保后端服务运行在 http://127.0.0.1:8787" + echo "2. 配置 MCP 客户端,添加 Moncter MCP 服务器" + echo "3. 查看 MCP/MCP服务器使用说明.md 了解详细用法" +else + echo "❌ 编译失败,请检查错误信息" + exit 1 +fi + diff --git a/MCP/moncter-mcp-server/package-lock.json b/MCP/moncter-mcp-server/package-lock.json new file mode 100644 index 0000000..3697e07 --- /dev/null +++ b/MCP/moncter-mcp-server/package-lock.json @@ -0,0 +1,1103 @@ +{ + "name": "moncter-mcp-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "moncter-mcp-server", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^0.5.0", + "node-fetch": "^3.3.2" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@types/node-fetch": "^2.6.11", + "tsx": "^4.7.0", + "typescript": "^5.3.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "0.5.0", + "resolved": "https://registry.npmmirror.com/@modelcontextprotocol/sdk/-/sdk-0.5.0.tgz", + "integrity": "sha512-RXgulUX6ewvxjAG0kOpLMEdXXWkzWgaoCGaA2CwNW7cQCIphjpJhjpHSiaPdVCnisjRF/0Cm9KWHUuIoeiAblQ==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "raw-body": "^3.0.0", + "zod": "^3.23.8" + } + }, + "node_modules/@types/node": { + "version": "20.19.27", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.27.tgz", + "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmmirror.com/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmmirror.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmmirror.com/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.1", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/MCP/moncter-mcp-server/package.json b/MCP/moncter-mcp-server/package.json new file mode 100644 index 0000000..6a6c17f --- /dev/null +++ b/MCP/moncter-mcp-server/package.json @@ -0,0 +1,26 @@ +{ + "name": "moncter-mcp-server", + "version": "1.0.0", + "description": "MCP Server for Moncter - Data Collection and Tag Task Management", + "main": "dist/index.js", + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx src/index.ts" + }, + "keywords": ["mcp", "moncter", "data-collection", "tag-task"], + "author": "", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^0.5.0", + "node-fetch": "^3.3.2" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@types/node-fetch": "^2.6.11", + "tsx": "^4.7.0", + "typescript": "^5.3.3" + } +} + diff --git a/MCP/moncter-mcp-server/src/index.ts b/MCP/moncter-mcp-server/src/index.ts new file mode 100644 index 0000000..473de05 --- /dev/null +++ b/MCP/moncter-mcp-server/src/index.ts @@ -0,0 +1,595 @@ +#!/usr/bin/env node + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; + +// 获取后端API基础URL(从环境变量或默认值) +const API_BASE_URL = process.env.MONCTER_API_URL || 'http://127.0.0.1:8787'; + +/** + * HTTP请求辅助函数 + */ +async function apiRequest( + method: string, + endpoint: string, + data?: any +): Promise { + const url = `${API_BASE_URL}${endpoint}`; + const options: RequestInit = { + method, + headers: { + 'Content-Type': 'application/json', + }, + }; + + if (data && (method === 'POST' || method === 'PUT')) { + options.body = JSON.stringify(data); + } + + try { + const response = await fetch(url, options); + const result = await response.json() as any; + + if (result.code !== 0 && result.code !== undefined) { + throw new Error(result.message || 'API请求失败'); + } + + return result.data || result; + } catch (error: any) { + throw new Error(`API请求错误: ${error.message}`); + } +} + +/** + * 创建MCP服务器 + */ +const server = new Server( + { + name: 'moncter-mcp-server', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + } +); + +// 列出可用工具 +server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [ + { + name: 'create_data_collection_task', + description: '创建数据采集任务。用于从数据源采集数据并转换为消费记录或其他格式。支持单集合和多集合模式,支持连表查询和过滤条件。', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: '任务名称(必填)', + }, + description: { + type: 'string', + description: '任务描述(可选)', + }, + data_source_id: { + type: 'string', + description: '源数据源ID(必填)', + }, + database: { + type: 'string', + description: '源数据库名称(必填)', + }, + collection: { + type: 'string', + description: '源集合名称(单集合模式,与collections二选一)', + }, + collections: { + type: 'array', + items: { type: 'string' }, + description: '源集合列表(多集合模式,与collection二选一)', + }, + multi_collection: { + type: 'boolean', + description: '是否启用多集合模式。true=使用collections字段,false=使用collection字段', + }, + target_type: { + type: 'string', + enum: ['consumption_record', 'generic'], + description: '目标类型(必填):consumption_record=消费记录处理(自动转换格式,通过手机号解析用户ID,时间分片存储到标签引擎数据库),generic=通用集合处理(需要自定义字段映射和目标存储配置)', + }, + mode: { + type: 'string', + enum: ['batch', 'realtime'], + description: '采集模式(必填):batch=批量采集(定时执行),realtime=实时监听(持续监听数据变化)', + }, + field_mappings: { + type: 'array', + description: '字段映射配置(单集合模式使用),将源字段映射到目标字段', + items: { + type: 'object', + properties: { + source_field: { + type: 'string', + description: '源字段名(查询结果中的字段)' + }, + target_field: { + type: 'string', + description: '目标字段名(Handler需要的字段名)' + }, + transform: { + type: 'string', + enum: ['parse_amount', 'parse_datetime', 'parse_phone'], + description: '转换函数(可选):parse_amount=解析金额,parse_datetime=解析日期时间,parse_phone=解析手机号' + }, + }, + required: ['source_field', 'target_field'], + }, + }, + collection_field_mappings: { + type: 'object', + description: '字段映射配置(多集合模式使用),格式:{ "collection_name": [FieldMapping] },每个集合可配置独立的字段映射', + additionalProperties: { + type: 'array', + items: { + type: 'object', + properties: { + source_field: { type: 'string' }, + target_field: { type: 'string' }, + transform: { type: 'string' }, + }, + }, + }, + }, + lookups: { + type: 'array', + description: 'MongoDB $lookup连表查询配置(单集合模式使用,可选),可以从其他集合关联数据', + items: { + type: 'object', + properties: { + from: { + type: 'string', + description: '关联集合名' + }, + local_field: { + type: 'string', + description: '主集合字段(用于关联的字段)' + }, + foreign_field: { + type: 'string', + description: '关联集合字段(被关联集合的字段,通常是_id)' + }, + as: { + type: 'string', + description: '结果字段名(关联结果存储的字段名)' + }, + unwrap: { + type: 'boolean', + description: '是否解构(true=解构后可直接使用user_info.mobile,false=返回数组)' + }, + preserve_null: { + type: 'boolean', + description: '是否保留空值(当关联不到数据时是否保留)' + }, + }, + required: ['from', 'local_field', 'foreign_field', 'as'], + }, + }, + collection_lookups: { + type: 'object', + description: 'MongoDB $lookup连表查询配置(多集合模式使用,可选),格式:{ "collection_name": [LookupConfig] },每个集合可配置独立的连表查询', + additionalProperties: { + type: 'array', + items: { + type: 'object', + properties: { + from: { type: 'string' }, + local_field: { type: 'string' }, + foreign_field: { type: 'string' }, + as: { type: 'string' }, + unwrap: { type: 'boolean' }, + preserve_null: { type: 'boolean' }, + }, + }, + }, + }, + filter_conditions: { + type: 'array', + description: '过滤条件(可选),只采集满足条件的数据', + items: { + type: 'object', + properties: { + field: { + type: 'string', + description: '字段名' + }, + operator: { + type: 'string', + enum: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin'], + description: '运算符:eq=等于,ne=不等于,gt=大于,gte=大于等于,lt=小于,lte=小于等于,in=在列表中,nin=不在列表中' + }, + value: { + type: ['string', 'number', 'boolean', 'array'], + description: '值(可以是字符串、数字、布尔值或数组)' + }, + }, + required: ['field', 'operator', 'value'], + }, + }, + schedule: { + type: 'object', + description: '调度配置(批量模式batch时使用)', + properties: { + enabled: { + type: 'boolean', + description: '是否启用调度(批量模式时可启用Cron定时执行)' + }, + cron: { + type: 'string', + description: 'Cron表达式(格式:分 时 日 月 周,例如:0 2 * * * 表示每天凌晨2点执行)' + }, + }, + }, + }, + required: ['name', 'data_source_id', 'database', 'mode', 'target_type'], + }, + }, + { + name: 'create_tag_task', + description: '创建标签计算任务。用于批量计算用户标签。', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: '任务名称', + }, + description: { + type: 'string', + description: '任务描述', + }, + task_type: { + type: 'string', + enum: ['full', 'incremental', 'specific'], + description: '任务类型:full=全量计算,incremental=增量计算,specific=指定用户', + }, + target_tag_ids: { + type: 'array', + items: { type: 'string' }, + description: '要计算的标签ID列表', + }, + user_scope: { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['all', 'list', 'filter'], + description: '用户范围类型', + }, + user_ids: { + type: 'array', + items: { type: 'string' }, + description: '用户ID列表(当type=list时)', + }, + filter_conditions: { + type: 'array', + description: '筛选条件(当type=filter时)', + items: { + type: 'object', + properties: { + field: { type: 'string' }, + operator: { type: 'string' }, + value: { type: 'string' }, + }, + }, + }, + }, + }, + schedule: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + cron: { type: 'string' }, + }, + }, + config: { + type: 'object', + properties: { + concurrency: { type: 'number' }, + batch_size: { type: 'number' }, + error_handling: { + type: 'string', + enum: ['skip', 'stop', 'retry'], + }, + }, + }, + }, + required: ['name', 'task_type', 'target_tag_ids'], + }, + }, + { + name: 'list_data_collection_tasks', + description: '获取数据采集任务列表', + inputSchema: { + type: 'object', + properties: { + page: { type: 'number', description: '页码' }, + page_size: { type: 'number', description: '每页数量' }, + }, + }, + }, + { + name: 'list_tag_tasks', + description: '获取标签任务列表', + inputSchema: { + type: 'object', + properties: { + page: { type: 'number', description: '页码' }, + page_size: { type: 'number', description: '每页数量' }, + }, + }, + }, + { + name: 'get_data_sources', + description: '获取数据源列表', + inputSchema: { + type: 'object', + properties: { + type: { type: 'string', description: '数据源类型筛选' }, + status: { type: 'number', description: '状态筛选:1=启用,0=禁用' }, + }, + }, + }, + { + name: 'get_tag_definitions', + description: '获取标签定义列表', + inputSchema: { + type: 'object', + properties: { + status: { type: 'number', description: '状态筛选:1=启用,0=禁用' }, + }, + }, + }, + { + name: 'start_data_collection_task', + description: '启动数据采集任务', + inputSchema: { + type: 'object', + properties: { + task_id: { + type: 'string', + description: '任务ID', + }, + }, + required: ['task_id'], + }, + }, + { + name: 'start_tag_task', + description: '启动标签任务', + inputSchema: { + type: 'object', + properties: { + task_id: { + type: 'string', + description: '任务ID', + }, + }, + required: ['task_id'], + }, + }, + ], + }; +}); + +// 处理工具调用 +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + try { + switch (name) { + case 'create_data_collection_task': { + const result = await apiRequest( + 'POST', + '/api/data-collection-tasks', + args + ); + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + message: '数据采集任务创建成功', + data: result, + }, + null, + 2 + ), + }, + ], + }; + } + + case 'create_tag_task': { + const result = await apiRequest('POST', '/api/tag-tasks', args); + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + message: '标签任务创建成功', + data: result, + }, + null, + 2 + ), + }, + ], + }; + } + + case 'list_data_collection_tasks': { + const params = new URLSearchParams(); + if (args?.page) params.append('page', String(args.page)); + if (args?.page_size) params.append('page_size', String(args.page_size)); + const query = params.toString(); + const endpoint = `/api/data-collection-tasks${query ? `?${query}` : ''}`; + const result = await apiRequest('GET', endpoint); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'list_tag_tasks': { + const params = new URLSearchParams(); + if (args?.page) params.append('page', String(args.page)); + if (args?.page_size) params.append('page_size', String(args.page_size)); + const query = params.toString(); + const endpoint = `/api/tag-tasks${query ? `?${query}` : ''}`; + const result = await apiRequest('GET', endpoint); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'get_data_sources': { + const params = new URLSearchParams(); + if (args?.type) params.append('type', String(args.type)); + if (args?.status !== undefined) params.append('status', String(args.status)); + const query = params.toString(); + const endpoint = `/api/data-sources${query ? `?${query}` : ''}`; + const result = await apiRequest('GET', endpoint); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'get_tag_definitions': { + const params = new URLSearchParams(); + if (args?.status !== undefined) params.append('status', String(args.status)); + const query = params.toString(); + const endpoint = `/api/tag-definitions${query ? `?${query}` : ''}`; + const result = await apiRequest('GET', endpoint); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'start_data_collection_task': { + if (!args?.task_id) { + throw new Error('缺少必需参数: task_id'); + } + const result = await apiRequest( + 'POST', + `/api/data-collection-tasks/${args.task_id}/start`, + {} + ); + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + message: '数据采集任务启动成功', + data: result, + }, + null, + 2 + ), + }, + ], + }; + } + + case 'start_tag_task': { + if (!args?.task_id) { + throw new Error('缺少必需参数: task_id'); + } + const result = await apiRequest( + 'POST', + `/api/tag-tasks/${args.task_id}/start`, + {} + ); + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + message: '标签任务启动成功', + data: result, + }, + null, + 2 + ), + }, + ], + }; + } + + default: + throw new Error(`未知的工具: ${name}`); + } + } catch (error: any) { + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: false, + error: error.message, + }, + null, + 2 + ), + }, + ], + isError: true, + }; + } +}); + +// 启动服务器 +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('Moncter MCP Server running on stdio'); +} + +main().catch((error) => { + console.error('服务器启动失败:', error); + process.exit(1); +}); + diff --git a/MCP/moncter-mcp-server/tsconfig.json b/MCP/moncter-mcp-server/tsconfig.json new file mode 100644 index 0000000..6582474 --- /dev/null +++ b/MCP/moncter-mcp-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "lib": ["ES2022"], + "moduleResolution": "node", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/MCP/实现总结.md b/MCP/实现总结.md new file mode 100644 index 0000000..6231f78 --- /dev/null +++ b/MCP/实现总结.md @@ -0,0 +1,164 @@ +# Moncter MCP 服务器实现总结 + +## 一、实现概述 + +已成功为 Moncter 系统创建了一个 MCP (Model Context Protocol) 服务器,允许通过 MCP 协议来管理数据采集任务和标签任务。 + +## 二、已创建的文件 + +### 1. 核心代码 + +- `MCP/moncter-mcp-server/src/index.ts` - MCP 服务器主文件 +- `MCP/moncter-mcp-server/package.json` - Node.js 依赖配置 +- `MCP/moncter-mcp-server/tsconfig.json` - TypeScript 配置 + +### 2. 配置文件 + +- `MCP/mcp.json` - MCP 服务器配置(已更新,添加了 Moncter 服务器) +- `MCP/mcp.json.example` - 配置示例文件 + +### 3. 安装脚本 + +- `MCP/moncter-mcp-server/install.sh` - Linux/Mac 安装脚本 +- `MCP/moncter-mcp-server/install.bat` - Windows 安装脚本 + +### 4. 文档 + +- `MCP/README.md` - MCP 目录说明 +- `MCP/快速开始.md` - 快速开始指南 +- `MCP/MCP服务器使用说明.md` - 详细使用说明 +- `MCP/moncter-mcp-server/README.md` - 服务器开发文档 + +## 三、实现的 MCP 工具 + +### 1. 数据采集任务管理 + +- ✅ `create_data_collection_task` - 创建数据采集任务 +- ✅ `list_data_collection_tasks` - 获取数据采集任务列表 +- ✅ `start_data_collection_task` - 启动数据采集任务 + +### 2. 标签任务管理 + +- ✅ `create_tag_task` - 创建标签计算任务 +- ✅ `list_tag_tasks` - 获取标签任务列表 +- ✅ `start_tag_task` - 启动标签任务 + +### 3. 辅助工具 + +- ✅ `get_data_sources` - 获取数据源列表 +- ✅ `get_tag_definitions` - 获取标签定义列表 + +## 四、技术实现 + +### 架构设计 + +``` +MCP Client (Claude Desktop, etc.) + ↓ (stdio/stdin) +Moncter MCP Server (Node.js) + ↓ (HTTP REST API) +Moncter Backend (PHP/Webman) + ↓ +MongoDB / Redis / RabbitMQ +``` + +### 关键技术 + +- **MCP SDK**: 使用 `@modelcontextprotocol/sdk` 实现 MCP 协议 +- **HTTP 客户端**: 使用原生 `fetch` API(Node.js 18+) +- **TypeScript**: 类型安全的实现 +- **Stdio Transport**: 通过标准输入输出与 MCP 客户端通信 + +## 五、安装和使用步骤 + +### 1. 安装 + +```bash +cd MCP/moncter-mcp-server +npm install +npm run build +``` + +### 2. 配置 + +编辑 `MCP/mcp.json`,确保路径正确: + +```json +{ + "mcpServers": { + "Moncter": { + "command": "node", + "args": ["./MCP/moncter-mcp-server/dist/index.js"], + "cwd": "YOUR_PROJECT_PATH", + "env": { + "MONCTER_API_URL": "http://127.0.0.1:8787" + } + } + } +} +``` + +### 3. 使用 + +在支持 MCP 的客户端(如 Claude Desktop)中: +- 配置 MCP 服务器(引用 `mcp.json`) +- 重启客户端 +- 通过对话使用工具:"创建一个数据采集任务..." + +## 六、功能特点 + +1. **完整的任务管理**: 支持创建、查询、启动数据采集任务和标签任务 +2. **参数验证**: 通过 JSON Schema 验证工具参数 +3. **错误处理**: 完善的错误处理和错误信息返回 +4. **类型安全**: 使用 TypeScript 确保类型安全 +5. **易于扩展**: 可以轻松添加新的 MCP 工具 + +## 七、后续扩展建议 + +### 可以添加的工具 + +1. **任务管理工具**: + - `update_data_collection_task` - 更新数据采集任务 + - `delete_data_collection_task` - 删除数据采集任务 + - `pause_data_collection_task` - 暂停数据采集任务 + - `stop_data_collection_task` - 停止数据采集任务 + - 类似的标签任务管理工具 + +2. **数据源管理工具**: + - `create_data_source` - 创建数据源 + - `update_data_source` - 更新数据源 + - `test_data_source_connection` - 测试数据源连接 + +3. **标签定义管理工具**: + - `create_tag_definition` - 创建标签定义 + - `update_tag_definition` - 更新标签定义 + +4. **查询工具**: + - `get_task_detail` - 获取任务详情 + - `get_task_progress` - 获取任务进度 + - `get_task_executions` - 获取任务执行记录 + +5. **批量操作工具**: + - `batch_create_tasks` - 批量创建任务 + - `batch_start_tasks` - 批量启动任务 + +## 八、注意事项 + +1. **Node.js 版本**: 需要 Node.js >= 18(使用原生 fetch API) +2. **后端服务**: 确保后端服务运行在配置的端口 +3. **路径配置**: MCP 配置中的路径需要根据实际情况修改 +4. **权限控制**: MCP 工具直接调用后端API,需要考虑权限控制 +5. **错误处理**: 工具调用失败时会返回错误信息,便于调试 + +## 九、测试建议 + +1. **单元测试**: 为各个工具函数编写单元测试 +2. **集成测试**: 测试与后端API的集成 +3. **端到端测试**: 使用 MCP Inspector 进行端到端测试 +4. **错误场景测试**: 测试各种错误场景的处理 + +--- + +**实现完成时间**: 2025-01-24 +**版本**: 1.0.0 + diff --git a/MCP/快速开始.md b/MCP/快速开始.md new file mode 100644 index 0000000..cae31b2 --- /dev/null +++ b/MCP/快速开始.md @@ -0,0 +1,212 @@ +# Moncter MCP Server 快速开始 + +## 一、安装 MCP Server + +### Windows + +```bash +cd MCP/moncter-mcp-server +install.bat +``` + +### Linux/Mac + +```bash +cd MCP/moncter-mcp-server +chmod +x install.sh +./install.sh +``` + +### 手动安装 + +```bash +cd MCP/moncter-mcp-server +npm install +npm run build +``` + +## 二、配置 MCP 客户端 + +### 方式1:使用相对路径(推荐) + +编辑 `MCP/mcp.json`,使用相对于项目根目录的路径: + +```json +{ + "mcpServers": { + "Moncter": { + "command": "node", + "args": ["./MCP/moncter-mcp-server/dist/index.js"], + "cwd": "E:/Cunkebao/Cunkebao02/Moncter", + "env": { + "MONCTER_API_URL": "http://127.0.0.1:8787" + } + } + } +} +``` + +**注意**:`cwd` 需要修改为你的实际项目路径。 + +### 方式2:使用绝对路径 + +```json +{ + "mcpServers": { + "Moncter": { + "command": "node", + "args": ["E:/Cunkebao/Cunkebao02/Moncter/MCP/moncter-mcp-server/dist/index.js"], + "env": { + "MONCTER_API_URL": "http://127.0.0.1:8787" + } + } + } +} +``` + +### 方式3:使用 npx(如果发布到npm) + +```json +{ + "mcpServers": { + "Moncter": { + "command": "npx", + "args": ["-y", "moncter-mcp-server"], + "env": { + "MONCTER_API_URL": "http://127.0.0.1:8787" + } + } + } +} +``` + +## 三、确保后端服务运行 + +确保 Moncter 后端服务正在运行: + +```bash +# 检查服务状态 +php start.php status + +# 如果未运行,启动服务 +php start.php start +``` + +默认端口:`8787` + +## 四、测试 MCP Server + +### 在 Claude Desktop 中使用 + +1. 打开 Claude Desktop +2. 在设置中添加 MCP 服务器配置(引用 `mcp.json`) +3. 重启 Claude Desktop +4. 在对话中尝试:"列出所有数据源" + +### 使用 MCP Inspector 测试 + +```bash +# 安装 MCP Inspector +npm install -g @modelcontextprotocol/inspector + +# 测试服务器 +cd MCP/moncter-mcp-server +npx @modelcontextprotocol/inspector node dist/index.js +``` + +## 五、使用示例 + +### 示例1:创建数据采集任务 + +对 AI 说: +> "创建一个实时监听的数据采集任务,名称为'订单采集',从数据源'data_source_123'的数据库'KR_商城'的集合'21年贝蒂喜订单整合'采集数据" + +AI 会调用 `create_data_collection_task` 工具来创建任务。 + +### 示例2:创建标签任务 + +对 AI 说: +> "创建一个全量标签计算任务,名称为'高价值用户标签',计算所有标签,每天凌晨2点执行" + +AI 会调用 `create_tag_task` 工具来创建任务。 + +### 示例3:查询数据源 + +对 AI 说: +> "列出所有启用的数据源" + +AI 会调用 `get_data_sources` 工具。 + +## 六、可用的工具列表 + +| 工具名称 | 功能 | 主要参数 | +|---------|------|---------| +| `create_data_collection_task` | 创建数据采集任务 | name, data_source_id, database, collection, mode | +| `create_tag_task` | 创建标签任务 | name, task_type, target_tag_ids | +| `list_data_collection_tasks` | 列出数据采集任务 | page, page_size | +| `list_tag_tasks` | 列出标签任务 | page, page_size | +| `get_data_sources` | 获取数据源列表 | type, status | +| `get_tag_definitions` | 获取标签定义列表 | status | +| `start_data_collection_task` | 启动数据采集任务 | task_id | +| `start_tag_task` | 启动标签任务 | task_id | + +详细参数说明请查看 `MCP/MCP服务器使用说明.md`。 + +## 七、故障排除 + +### 问题1:找不到模块 + +**错误**:`Cannot find module '@modelcontextprotocol/sdk'` + +**解决**: +```bash +cd MCP/moncter-mcp-server +npm install +``` + +### 问题2:编译失败 + +**错误**:TypeScript 编译错误 + +**解决**: +- 检查 Node.js 版本(需要 >= 18) +- 检查 TypeScript 版本 +- 运行 `npm install` 重新安装依赖 + +### 问题3:无法连接到后端 + +**错误**:`API请求错误: connect ECONNREFUSED` + +**解决**: +1. 检查后端服务是否运行:`php start.php status` +2. 检查 `MONCTER_API_URL` 环境变量是否正确 +3. 检查端口是否被占用:`netstat -ano | findstr :8787` + +### 问题4:路径错误 + +**错误**:`Cannot find module` 或路径相关错误 + +**解决**: +- 检查 `mcp.json` 中的路径是否正确 +- 使用绝对路径而不是相对路径 +- 确保 `cwd` 设置正确 + +## 八、开发调试 + +### 查看日志 + +MCP 服务器的错误日志会输出到 stderr,可以在 MCP 客户端中查看。 + +### 本地测试 + +```bash +cd MCP/moncter-mcp-server +npm run dev +``` + +然后使用 MCP Inspector 连接测试。 + +--- + +**需要帮助?** 查看 `MCP/MCP服务器使用说明.md` 获取详细文档。 + diff --git a/README.en.md b/README.en.md deleted file mode 100644 index 09ba594..0000000 --- a/README.en.md +++ /dev/null @@ -1,36 +0,0 @@ -# 数据中心 - -#### Description -Serverruntime - -#### Software Architecture -Software architecture description - -#### Installation - -1. xxxx -2. xxxx -3. xxxx - -#### Instructions - -1. xxxx -2. xxxx -3. xxxx - -#### Contribution - -1. Fork the repository -2. Create Feat_xxx branch -3. Commit your code -4. Create Pull Request - - -#### Gitee Feature - -1. You can use Readme\_XXX.md to support different languages, such as Readme\_en.md, Readme\_zh.md -2. Gitee blog [blog.gitee.com](https://blog.gitee.com) -3. Explore open source project [https://gitee.com/explore](https://gitee.com/explore) -4. The most valuable open source project [GVP](https://gitee.com/gvp) -5. The manual of Gitee [https://gitee.com/help](https://gitee.com/help) -6. The most popular members [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/) diff --git a/TaskShow/.editorconfig b/TaskShow/.editorconfig new file mode 100644 index 0000000..36894d9 --- /dev/null +++ b/TaskShow/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + diff --git a/TaskShow/.gitignore b/TaskShow/.gitignore new file mode 100644 index 0000000..3309ef1 --- /dev/null +++ b/TaskShow/.gitignore @@ -0,0 +1,29 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Environment variables +.env.local +.env.*.local + diff --git a/TaskShow/README.md b/TaskShow/README.md new file mode 100644 index 0000000..2dac432 --- /dev/null +++ b/TaskShow/README.md @@ -0,0 +1,127 @@ +# Task Show + +基于 Vue3 + Element Plus + Pinia + TypeScript + Axios 的前端基础工程 + +## 技术栈 + +- **Vue 3** - 渐进式 JavaScript 框架 +- **TypeScript** - JavaScript 的超集 +- **Vite** - 下一代前端构建工具 +- **Element Plus** - 基于 Vue 3 的组件库 +- **Pinia** - Vue 的状态管理库 +- **Vue Router** - Vue 官方路由管理器 +- **Axios** - 基于 Promise 的 HTTP 客户端 + +## 项目结构 + +``` +TaskShow/ +├── src/ +│ ├── assets/ # 静态资源 +│ ├── components/ # 公共组件 +│ ├── router/ # 路由配置 +│ │ └── index.ts +│ ├── store/ # Pinia 状态管理 +│ │ └── index.ts +│ ├── types/ # TypeScript 类型定义 +│ │ └── api.ts +│ ├── utils/ # 工具函数 +│ │ └── request.ts # Axios 请求封装 +│ ├── views/ # 页面组件 +│ │ └── Home.vue +│ ├── App.vue # 根组件 +│ └── main.ts # 入口文件 +├── index.html # HTML 模板 +├── package.json # 项目配置 +├── tsconfig.json # TypeScript 配置 +├── vite.config.ts # Vite 配置 +└── README.md # 项目说明 +``` + +## 安装依赖 + +```bash +npm install +# 或 +yarn install +# 或 +pnpm install +``` + +## 开发 + +```bash +npm run dev +# 或 +yarn dev +# 或 +pnpm dev +``` + +## 构建 + +```bash +npm run build +# 或 +yarn build +# 或 +pnpm build +``` + +## 预览构建结果 + +```bash +npm run preview +# 或 +yarn preview +# 或 +pnpm preview +``` + +## 请求封装说明 + +### 使用方式 + +```typescript +import { request } from '@/utils/request' + +// GET 请求 +const response = await request.get('/api/users', { id: 1 }) + +// POST 请求 +const response = await request.post('/api/users', { name: 'John' }) + +// PUT 请求 +const response = await request.put('/api/users/1', { name: 'Jane' }) + +// DELETE 请求 +const response = await request.delete('/api/users/1') + +// 自定义配置 +const response = await request.get('/api/users', {}, { + showLoading: false, // 不显示 loading + showError: false, // 不显示错误提示 + timeout: 5000 // 自定义超时时间 +}) +``` + +### 特性 + +1. **自动添加 Token**:请求时自动从 store 中获取 token 并添加到请求头 +2. **统一错误处理**:自动处理 HTTP 错误和业务错误 +3. **Loading 提示**:请求时自动显示 loading(可配置) +4. **错误提示**:请求失败时自动显示错误消息(可配置) +5. **类型支持**:完整的 TypeScript 类型定义 + +### 环境变量 + +在 `.env`、`.env.development` 或 `.env.production` 文件中配置 API 基础地址: + +``` +VITE_API_BASE_URL=/api +``` + +## License + +MIT + diff --git a/TaskShow/USAGE.md b/TaskShow/USAGE.md new file mode 100644 index 0000000..9347a00 --- /dev/null +++ b/TaskShow/USAGE.md @@ -0,0 +1,154 @@ +# 使用说明 + +## 快速开始 + +### 1. 安装依赖 + +```bash +cd TaskShow +npm install +``` + +### 2. 启动开发服务器 + +```bash +npm run dev +``` + +### 3. 构建生产版本 + +```bash +npm run build +``` + +## 核心功能使用 + +### 1. 使用封装的请求方法 + +```typescript +import { request } from '@/utils/request' + +// GET 请求 +const getUserList = async () => { + try { + const response = await request.get('/api/users', { page: 1, pageSize: 10 }) + console.log(response.data) // 响应数据 + } catch (error) { + console.error('请求失败:', error) + } +} + +// POST 请求 +const createUser = async () => { + try { + const response = await request.post('/api/users', { + name: 'John', + email: 'john@example.com' + }) + console.log(response.data) + } catch (error) { + console.error('创建失败:', error) + } +} + +// 自定义配置 +const customRequest = async () => { + const response = await request.get('/api/users', {}, { + showLoading: false, // 不显示 loading + showError: false, // 不显示错误提示 + timeout: 5000 // 5秒超时 + }) +} +``` + +### 2. 使用 Pinia Store + +```typescript +import { useUserStore } from '@/store' + +// 在组件中使用 +const userStore = useUserStore() + +// 设置 token +userStore.setToken('your-token-here') + +// 设置用户信息 +userStore.setUserInfo({ id: 1, name: 'John' }) + +// 清除用户信息 +userStore.clearUser() + +// 访问状态 +console.log(userStore.token) +console.log(userStore.userInfo) +``` + +### 3. 使用路由 + +```typescript +import { useRouter, useRoute } from 'vue-router' + +const router = useRouter() +const route = useRoute() + +// 编程式导航 +router.push('/home') +router.push({ name: 'Home', params: { id: 1 } }) + +// 获取路由参数 +const id = route.params.id +``` + +### 4. 使用 Element Plus 组件 + +```vue + + + +``` + +## 项目结构说明 + +- `src/api/` - API 接口定义 +- `src/components/` - 公共组件 +- `src/router/` - 路由配置 +- `src/store/` - Pinia 状态管理 +- `src/types/` - TypeScript 类型定义 +- `src/utils/` - 工具函数(包含封装的 request) +- `src/views/` - 页面组件 + +## 环境变量配置 + +在项目根目录创建 `.env.development` 和 `.env.production` 文件: + +```bash +# .env.development +VITE_API_BASE_URL=http://localhost:8080/api + +# .env.production +VITE_API_BASE_URL=https://api.example.com/api +``` + +## 注意事项 + +1. 所有 API 请求会自动添加 token(如果存在) +2. 请求失败会自动显示错误提示(可通过配置关闭) +3. 请求时会自动显示 loading(可通过配置关闭) +4. 401 错误会自动清除用户信息并提示登录 diff --git a/TaskShow/index.html b/TaskShow/index.html new file mode 100644 index 0000000..c9935d0 --- /dev/null +++ b/TaskShow/index.html @@ -0,0 +1,14 @@ + + + + + + + Task Show + + +
+ + + + diff --git a/TaskShow/package-lock.json b/TaskShow/package-lock.json new file mode 100644 index 0000000..0359da9 --- /dev/null +++ b/TaskShow/package-lock.json @@ -0,0 +1,2846 @@ +{ + "name": "task-show", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "task-show", + "version": "1.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.6.7", + "element-plus": "^2.5.6", + "pinia": "^2.1.7", + "vue": "^3.4.21", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@types/node": "^20.11.24", + "@vitejs/plugin-vue": "^5.0.4", + "sass-embedded": "^1.97.1", + "typescript": "^5.4.2", + "vite": "^5.1.6", + "vue-tsc": "^1.8.27" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.10.2", + "resolved": "https://registry.npmmirror.com/@bufbuild/protobuf/-/protobuf-2.10.2.tgz", + "integrity": "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==", + "dev": true, + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.7", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz", + "integrity": "sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.27", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.27.tgz", + "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-1.11.1.tgz", + "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "1.11.1" + } + }, + "node_modules/@volar/source-map": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-1.11.1.tgz", + "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "muggle-string": "^0.3.1" + } + }, + "node_modules/@volar/typescript": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-1.11.1.tgz", + "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "1.11.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.26", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.26.tgz", + "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.26", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz", + "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.26", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz", + "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.26", + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.26", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz", + "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "1.8.27", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-1.8.27.tgz", + "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "~1.11.1", + "@volar/source-map": "~1.11.1", + "@vue/compiler-dom": "^3.3.0", + "@vue/shared": "^3.3.0", + "computeds": "^0.0.1", + "minimatch": "^9.0.3", + "muggle-string": "^0.3.1", + "path-browserify": "^1.0.1", + "vue-template-compiler": "^2.7.14" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.26", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.26.tgz", + "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.26", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.26.tgz", + "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz", + "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.26", + "@vue/runtime-core": "3.5.26", + "@vue/shared": "3.5.26", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.26", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.26.tgz", + "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26" + }, + "peerDependencies": { + "vue": "3.5.26" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.26", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.26.tgz", + "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "10.11.1", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-10.11.1.tgz", + "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.11.1", + "@vueuse/shared": "10.11.1", + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "10.11.1", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-10.11.1.tgz", + "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "10.11.1", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-10.11.1.tgz", + "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", + "license": "MIT", + "dependencies": { + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-builder": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/buffer-builder/-/buffer-builder-0.2.0.tgz", + "integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==", + "dev": true, + "license": "MIT/X11" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/colorjs.io": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/colorjs.io/-/colorjs.io-0.5.2.tgz", + "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/computeds": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/computeds/-/computeds-0.0.1.tgz", + "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.13.0", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.0.tgz", + "integrity": "sha512-qjxS+SBChvqCl6lU6ShiliLMN6WqFHiXQENYbAY3GKNflG+FS3jqn8JmQq0CBZq4koFqsi95NT1M6SL4whZfrA==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.4.1", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "^10.11.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.0.tgz", + "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.22", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.22.tgz", + "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.3.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.3.1.tgz", + "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/sass": { + "version": "1.97.1", + "resolved": "https://registry.npmmirror.com/sass/-/sass-1.97.1.tgz", + "integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass-embedded": { + "version": "1.97.1", + "resolved": "https://registry.npmmirror.com/sass-embedded/-/sass-embedded-1.97.1.tgz", + "integrity": "sha512-wH3CbOThHYGX0bUyqFf7laLKyhVWIFc2lHynitkqMIUCtX2ixH9mQh0bN7+hkUu5BFt/SXvEMjFbkEbBMpQiSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bufbuild/protobuf": "^2.5.0", + "buffer-builder": "^0.2.0", + "colorjs.io": "^0.5.0", + "immutable": "^5.0.2", + "rxjs": "^7.4.0", + "supports-color": "^8.1.1", + "sync-child-process": "^1.0.2", + "varint": "^6.0.0" + }, + "bin": { + "sass": "dist/bin/sass.js" + }, + "engines": { + "node": ">=16.0.0" + }, + "optionalDependencies": { + "sass-embedded-all-unknown": "1.97.1", + "sass-embedded-android-arm": "1.97.1", + "sass-embedded-android-arm64": "1.97.1", + "sass-embedded-android-riscv64": "1.97.1", + "sass-embedded-android-x64": "1.97.1", + "sass-embedded-darwin-arm64": "1.97.1", + "sass-embedded-darwin-x64": "1.97.1", + "sass-embedded-linux-arm": "1.97.1", + "sass-embedded-linux-arm64": "1.97.1", + "sass-embedded-linux-musl-arm": "1.97.1", + "sass-embedded-linux-musl-arm64": "1.97.1", + "sass-embedded-linux-musl-riscv64": "1.97.1", + "sass-embedded-linux-musl-x64": "1.97.1", + "sass-embedded-linux-riscv64": "1.97.1", + "sass-embedded-linux-x64": "1.97.1", + "sass-embedded-unknown-all": "1.97.1", + "sass-embedded-win32-arm64": "1.97.1", + "sass-embedded-win32-x64": "1.97.1" + } + }, + "node_modules/sass-embedded-all-unknown": { + "version": "1.97.1", + "resolved": "https://registry.npmmirror.com/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.97.1.tgz", + "integrity": "sha512-0au5gUNibfob7W/g+ycBx74O22CL8vwHiZdEDY6J0uzMkHPiSJk//h0iRf5AUnMArFHJjFd3urIiQIaoRKYa1Q==", + "cpu": [ + "!arm", + "!arm64", + "!riscv64", + "!x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "sass": "1.97.1" + } + }, + "node_modules/sass-embedded-android-arm": { + "version": "1.97.1", + "resolved": "https://registry.npmmirror.com/sass-embedded-android-arm/-/sass-embedded-android-arm-1.97.1.tgz", + "integrity": "sha512-B5dlv4utJ+yC8ZpBeWTHwSZPVKRlqA8pcaD0FAzeNm/DelIFgQUQtt0UwgYoAI6wDIiie5uSVpMK9l2DaCbiBQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-arm64": { + "version": "1.97.1", + "resolved": "https://registry.npmmirror.com/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.97.1.tgz", + "integrity": "sha512-h62DmOiS2Jn87s8+8GhJcMerJnTKa1IsIa9iIKjLiqbAvBDKCGUs027RugZkM+Zx7I+vhPq86PUXBYZ9EkRxdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-riscv64": { + "version": "1.97.1", + "resolved": "https://registry.npmmirror.com/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.97.1.tgz", + "integrity": "sha512-tGup88vgaXPnUHEgDMujrt5rfYadvkiVjRb/45FJTx2hQFoGVbmUXz5XqUFjIIbEjQ3kAJqp86A2jy11s43UiQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-x64": { + "version": "1.97.1", + "resolved": "https://registry.npmmirror.com/sass-embedded-android-x64/-/sass-embedded-android-x64-1.97.1.tgz", + "integrity": "sha512-CAzKjjzu90LZduye2O9+UGX1oScMyF5/RVOa5CxACKALeIS+3XL3LVdV47kwKPoBv5B1aFUvGLscY0CR7jBAbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-arm64": { + "version": "1.97.1", + "resolved": "https://registry.npmmirror.com/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.97.1.tgz", + "integrity": "sha512-tyDzspzh5PbqdAFGtVKUXuf0up6Lff3c1U8J7+4Y7jW6AWRBnq95vTzIIxfnNifGCTI2fW5e7GAZpYygKpNwcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-x64": { + "version": "1.97.1", + "resolved": "https://registry.npmmirror.com/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.97.1.tgz", + "integrity": "sha512-FMrRuSPI2ICt2M2SYaLbiG4yxn86D6ae+XtrRdrrBMhWprAcB7Iyu67bgRzZkipMZNIKKeTR7EUvJHgZzi5ixQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm": { + "version": "1.97.1", + "resolved": "https://registry.npmmirror.com/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.97.1.tgz", + "integrity": "sha512-48VxaTUApLyx1NXFdZhKqI/7FYLmz8Ju3Ki2V/p+mhn5raHgAiYeFgn8O1WGxTOh+hBb9y3FdSR5a8MNTbmKMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm64": { + "version": "1.97.1", + "resolved": "https://registry.npmmirror.com/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.97.1.tgz", + "integrity": "sha512-im80gfDWRivw9Su3r3YaZmJaCATcJgu3CsCSLodPk1b1R2+X/E12zEQayvrl05EGT9PDwTtuiqKgS4ND4xjwVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm": { + "version": "1.97.1", + "resolved": "https://registry.npmmirror.com/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.97.1.tgz", + "integrity": "sha512-FUFs466t3PVViVOKY/60JgLLtl61Pf7OW+g5BeEfuqVcSvYUECVHeiYHtX1fT78PEVa0h9tHpM6XpWti+7WYFA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm64": { + "version": "1.97.1", + "resolved": "https://registry.npmmirror.com/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.97.1.tgz", + "integrity": "sha512-kD35WSD9o0279Ptwid3Jnbovo1FYnuG2mayYk9z4ZI4mweXEK6vTu+tlvCE/MdF/zFKSj11qaxaH+uzXe2cO5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-riscv64": { + "version": "1.97.1", + "resolved": "https://registry.npmmirror.com/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.97.1.tgz", + "integrity": "sha512-ZgpYps5YHuhA2+KiLkPukRbS5298QObgUhPll/gm5i0LOZleKCwrFELpVPcbhsSBuxqji2uaag5OL+n3JRBVVg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-x64": { + "version": "1.97.1", + "resolved": "https://registry.npmmirror.com/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.97.1.tgz", + "integrity": "sha512-wcAigOyyvZ6o1zVypWV7QLZqpOEVnlBqJr9MbpnRIm74qFTSbAEmShoh8yMXBymzuVSmEbThxAwW01/TLf62tA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-riscv64": { + "version": "1.97.1", + "resolved": "https://registry.npmmirror.com/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.97.1.tgz", + "integrity": "sha512-9j1qE1ZrLMuGb+LUmBzw93Z4TNfqlRkkxjPVZy6u5vIggeSfvGbte7eRoYBNWX6SFew/yBCL90KXIirWFSGrlQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-x64": { + "version": "1.97.1", + "resolved": "https://registry.npmmirror.com/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.97.1.tgz", + "integrity": "sha512-7nrLFYMH/UgvEgXR5JxQJ6y9N4IJmnFnYoDxN0nw0jUp+CQWQL4EJ4RqAKTGelneueRbccvt2sEyPK+X0KJ9Jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-unknown-all": { + "version": "1.97.1", + "resolved": "https://registry.npmmirror.com/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.97.1.tgz", + "integrity": "sha512-oPSeKc7vS2dx3ZJHiUhHKcyqNq0GWzAiR8zMVpPd/kVMl5ZfVyw+5HTCxxWDBGkX02lNpou27JkeBPCaneYGAQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "!android", + "!darwin", + "!linux", + "!win32" + ], + "dependencies": { + "sass": "1.97.1" + } + }, + "node_modules/sass-embedded-win32-arm64": { + "version": "1.97.1", + "resolved": "https://registry.npmmirror.com/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.97.1.tgz", + "integrity": "sha512-L5j7J6CbZgHGwcfVedMVpM3z5MYeighcyZE8GF2DVmjWzZI3JtPKNY11wNTD/P9o1Uql10YPOKhGH0iWIXOT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-win32-x64": { + "version": "1.97.1", + "resolved": "https://registry.npmmirror.com/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.97.1.tgz", + "integrity": "sha512-rfaZAKXU8cW3E7gvdafyD6YtgbEcsDeT99OEiHXRT0UGFuXT8qCOjpAwIKaOA3XXr2d8S42xx6cXcaZ1a+1fgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/sync-child-process": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/sync-child-process/-/sync-child-process-1.0.2.tgz", + "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sync-message-port": "^1.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/sync-message-port": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/sync-message-port/-/sync-message-port-1.1.3.tgz", + "integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/varint": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/varint/-/varint-6.0.0.tgz", + "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.26", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.26.tgz", + "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-sfc": "3.5.26", + "@vue/runtime-dom": "3.5.26", + "@vue/server-renderer": "3.5.26", + "@vue/shared": "3.5.26" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-template-compiler": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", + "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/vue-tsc": { + "version": "1.8.27", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-1.8.27.tgz", + "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "~1.11.1", + "@vue/language-core": "1.8.27", + "semver": "^7.5.4" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": "*" + } + } + } +} diff --git a/TaskShow/package.json b/TaskShow/package.json new file mode 100644 index 0000000..ea3b342 --- /dev/null +++ b/TaskShow/package.json @@ -0,0 +1,27 @@ +{ + "name": "task-show", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview", + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.6.7", + "element-plus": "^2.5.6", + "pinia": "^2.1.7", + "vue": "^3.4.21", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@types/node": "^20.11.24", + "@vitejs/plugin-vue": "^5.0.4", + "sass-embedded": "^1.97.1", + "typescript": "^5.4.2", + "vite": "^5.1.6", + "vue-tsc": "^1.8.27" + } +} diff --git a/TaskShow/pnpm-lock.yaml b/TaskShow/pnpm-lock.yaml new file mode 100644 index 0000000..3f403d6 --- /dev/null +++ b/TaskShow/pnpm-lock.yaml @@ -0,0 +1,1794 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@element-plus/icons-vue': + specifier: ^2.3.1 + version: 2.3.2(vue@3.5.26(typescript@5.9.3)) + axios: + specifier: ^1.6.7 + version: 1.13.2 + element-plus: + specifier: ^2.5.6 + version: 2.13.0(vue@3.5.26(typescript@5.9.3)) + pinia: + specifier: ^2.1.7 + version: 2.3.1(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)) + vue: + specifier: ^3.4.21 + version: 3.5.26(typescript@5.9.3) + vue-router: + specifier: ^4.3.0 + version: 4.6.4(vue@3.5.26(typescript@5.9.3)) + devDependencies: + '@types/node': + specifier: ^20.11.24 + version: 20.19.27 + '@vitejs/plugin-vue': + specifier: ^5.0.4 + version: 5.2.4(vite@5.4.21(@types/node@20.19.27)(sass-embedded@1.97.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)) + sass-embedded: + specifier: ^1.97.1 + version: 1.97.1 + typescript: + specifier: ^5.4.2 + version: 5.9.3 + vite: + specifier: ^5.1.6 + version: 5.4.21(@types/node@20.19.27)(sass-embedded@1.97.1)(sass@1.97.1) + vue-tsc: + specifier: ^1.8.27 + version: 1.8.27(typescript@5.9.3) + +packages: + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@bufbuild/protobuf@2.10.2': + resolution: {integrity: sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==} + + '@ctrl/tinycolor@3.6.1': + resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==} + engines: {node: '>=10'} + + '@element-plus/icons-vue@2.3.2': + resolution: {integrity: sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==} + peerDependencies: + vue: ^3.2.0 + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@parcel/watcher-android-arm64@2.5.1': + resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.1': + resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.1': + resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.1': + resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.1': + resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.5.1': + resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.5.1': + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.5.1': + resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.1': + resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.1': + resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.1': + resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} + engines: {node: '>= 10.0.0'} + + '@rollup/rollup-android-arm-eabi@4.54.0': + resolution: {integrity: sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.54.0': + resolution: {integrity: sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.54.0': + resolution: {integrity: sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.54.0': + resolution: {integrity: sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.54.0': + resolution: {integrity: sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.54.0': + resolution: {integrity: sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.54.0': + resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.54.0': + resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.54.0': + resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.54.0': + resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.54.0': + resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.54.0': + resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.54.0': + resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.54.0': + resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.54.0': + resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.54.0': + resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.54.0': + resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.54.0': + resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.54.0': + resolution: {integrity: sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.54.0': + resolution: {integrity: sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.54.0': + resolution: {integrity: sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.54.0': + resolution: {integrity: sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==} + cpu: [x64] + os: [win32] + + '@sxzz/popperjs-es@2.11.7': + resolution: {integrity: sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.21': + resolution: {integrity: sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==} + + '@types/node@20.19.27': + resolution: {integrity: sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==} + + '@types/web-bluetooth@0.0.20': + resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@volar/language-core@1.11.1': + resolution: {integrity: sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==} + + '@volar/source-map@1.11.1': + resolution: {integrity: sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==} + + '@volar/typescript@1.11.1': + resolution: {integrity: sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==} + + '@vue/compiler-core@3.5.26': + resolution: {integrity: sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==} + + '@vue/compiler-dom@3.5.26': + resolution: {integrity: sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==} + + '@vue/compiler-sfc@3.5.26': + resolution: {integrity: sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==} + + '@vue/compiler-ssr@3.5.26': + resolution: {integrity: sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/language-core@1.8.27': + resolution: {integrity: sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.26': + resolution: {integrity: sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==} + + '@vue/runtime-core@3.5.26': + resolution: {integrity: sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==} + + '@vue/runtime-dom@3.5.26': + resolution: {integrity: sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==} + + '@vue/server-renderer@3.5.26': + resolution: {integrity: sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==} + peerDependencies: + vue: 3.5.26 + + '@vue/shared@3.5.26': + resolution: {integrity: sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==} + + '@vueuse/core@10.11.1': + resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==} + + '@vueuse/metadata@10.11.1': + resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==} + + '@vueuse/shared@10.11.1': + resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + buffer-builder@0.2.0: + resolution: {integrity: sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + colorjs.io@0.5.2: + resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + computeds@0.0.1: + resolution: {integrity: sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + element-plus@2.13.0: + resolution: {integrity: sha512-qjxS+SBChvqCl6lU6ShiliLMN6WqFHiXQENYbAY3GKNflG+FS3jqn8JmQq0CBZq4koFqsi95NT1M6SL4whZfrA==} + peerDependencies: + vue: ^3.3.0 + + entities@7.0.0: + resolution: {integrity: sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + immutable@5.1.4: + resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + lodash-es@4.17.22: + resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==} + + lodash-unified@1.0.3: + resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==} + peerDependencies: + '@types/lodash-es': '*' + lodash: '*' + lodash-es: '*' + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + muggle-string@0.3.1: + resolution: {integrity: sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + normalize-wheel-es@1.2.0: + resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pinia@2.3.1: + resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==} + peerDependencies: + typescript: '>=4.4.4' + vue: ^2.7.0 || ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + rollup@4.54.0: + resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + sass-embedded-all-unknown@1.97.1: + resolution: {integrity: sha512-0au5gUNibfob7W/g+ycBx74O22CL8vwHiZdEDY6J0uzMkHPiSJk//h0iRf5AUnMArFHJjFd3urIiQIaoRKYa1Q==} + cpu: ['!arm', '!arm64', '!riscv64', '!x64'] + + sass-embedded-android-arm64@1.97.1: + resolution: {integrity: sha512-h62DmOiS2Jn87s8+8GhJcMerJnTKa1IsIa9iIKjLiqbAvBDKCGUs027RugZkM+Zx7I+vhPq86PUXBYZ9EkRxdw==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [android] + + sass-embedded-android-arm@1.97.1: + resolution: {integrity: sha512-B5dlv4utJ+yC8ZpBeWTHwSZPVKRlqA8pcaD0FAzeNm/DelIFgQUQtt0UwgYoAI6wDIiie5uSVpMK9l2DaCbiBQ==} + engines: {node: '>=14.0.0'} + cpu: [arm] + os: [android] + + sass-embedded-android-riscv64@1.97.1: + resolution: {integrity: sha512-tGup88vgaXPnUHEgDMujrt5rfYadvkiVjRb/45FJTx2hQFoGVbmUXz5XqUFjIIbEjQ3kAJqp86A2jy11s43UiQ==} + engines: {node: '>=14.0.0'} + cpu: [riscv64] + os: [android] + + sass-embedded-android-x64@1.97.1: + resolution: {integrity: sha512-CAzKjjzu90LZduye2O9+UGX1oScMyF5/RVOa5CxACKALeIS+3XL3LVdV47kwKPoBv5B1aFUvGLscY0CR7jBAbg==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [android] + + sass-embedded-darwin-arm64@1.97.1: + resolution: {integrity: sha512-tyDzspzh5PbqdAFGtVKUXuf0up6Lff3c1U8J7+4Y7jW6AWRBnq95vTzIIxfnNifGCTI2fW5e7GAZpYygKpNwcw==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [darwin] + + sass-embedded-darwin-x64@1.97.1: + resolution: {integrity: sha512-FMrRuSPI2ICt2M2SYaLbiG4yxn86D6ae+XtrRdrrBMhWprAcB7Iyu67bgRzZkipMZNIKKeTR7EUvJHgZzi5ixQ==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [darwin] + + sass-embedded-linux-arm64@1.97.1: + resolution: {integrity: sha512-im80gfDWRivw9Su3r3YaZmJaCATcJgu3CsCSLodPk1b1R2+X/E12zEQayvrl05EGT9PDwTtuiqKgS4ND4xjwVg==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [linux] + + sass-embedded-linux-arm@1.97.1: + resolution: {integrity: sha512-48VxaTUApLyx1NXFdZhKqI/7FYLmz8Ju3Ki2V/p+mhn5raHgAiYeFgn8O1WGxTOh+hBb9y3FdSR5a8MNTbmKMQ==} + engines: {node: '>=14.0.0'} + cpu: [arm] + os: [linux] + + sass-embedded-linux-musl-arm64@1.97.1: + resolution: {integrity: sha512-kD35WSD9o0279Ptwid3Jnbovo1FYnuG2mayYk9z4ZI4mweXEK6vTu+tlvCE/MdF/zFKSj11qaxaH+uzXe2cO5A==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [linux] + + sass-embedded-linux-musl-arm@1.97.1: + resolution: {integrity: sha512-FUFs466t3PVViVOKY/60JgLLtl61Pf7OW+g5BeEfuqVcSvYUECVHeiYHtX1fT78PEVa0h9tHpM6XpWti+7WYFA==} + engines: {node: '>=14.0.0'} + cpu: [arm] + os: [linux] + + sass-embedded-linux-musl-riscv64@1.97.1: + resolution: {integrity: sha512-ZgpYps5YHuhA2+KiLkPukRbS5298QObgUhPll/gm5i0LOZleKCwrFELpVPcbhsSBuxqji2uaag5OL+n3JRBVVg==} + engines: {node: '>=14.0.0'} + cpu: [riscv64] + os: [linux] + + sass-embedded-linux-musl-x64@1.97.1: + resolution: {integrity: sha512-wcAigOyyvZ6o1zVypWV7QLZqpOEVnlBqJr9MbpnRIm74qFTSbAEmShoh8yMXBymzuVSmEbThxAwW01/TLf62tA==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [linux] + + sass-embedded-linux-riscv64@1.97.1: + resolution: {integrity: sha512-9j1qE1ZrLMuGb+LUmBzw93Z4TNfqlRkkxjPVZy6u5vIggeSfvGbte7eRoYBNWX6SFew/yBCL90KXIirWFSGrlQ==} + engines: {node: '>=14.0.0'} + cpu: [riscv64] + os: [linux] + + sass-embedded-linux-x64@1.97.1: + resolution: {integrity: sha512-7nrLFYMH/UgvEgXR5JxQJ6y9N4IJmnFnYoDxN0nw0jUp+CQWQL4EJ4RqAKTGelneueRbccvt2sEyPK+X0KJ9Jg==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [linux] + + sass-embedded-unknown-all@1.97.1: + resolution: {integrity: sha512-oPSeKc7vS2dx3ZJHiUhHKcyqNq0GWzAiR8zMVpPd/kVMl5ZfVyw+5HTCxxWDBGkX02lNpou27JkeBPCaneYGAQ==} + os: ['!android', '!darwin', '!linux', '!win32'] + + sass-embedded-win32-arm64@1.97.1: + resolution: {integrity: sha512-L5j7J6CbZgHGwcfVedMVpM3z5MYeighcyZE8GF2DVmjWzZI3JtPKNY11wNTD/P9o1Uql10YPOKhGH0iWIXOT7Q==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [win32] + + sass-embedded-win32-x64@1.97.1: + resolution: {integrity: sha512-rfaZAKXU8cW3E7gvdafyD6YtgbEcsDeT99OEiHXRT0UGFuXT8qCOjpAwIKaOA3XXr2d8S42xx6cXcaZ1a+1fgw==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [win32] + + sass-embedded@1.97.1: + resolution: {integrity: sha512-wH3CbOThHYGX0bUyqFf7laLKyhVWIFc2lHynitkqMIUCtX2ixH9mQh0bN7+hkUu5BFt/SXvEMjFbkEbBMpQiSQ==} + engines: {node: '>=16.0.0'} + hasBin: true + + sass@1.97.1: + resolution: {integrity: sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==} + engines: {node: '>=14.0.0'} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + sync-child-process@1.0.2: + resolution: {integrity: sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==} + engines: {node: '>=16.0.0'} + + sync-message-port@1.1.3: + resolution: {integrity: sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==} + engines: {node: '>=16.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + varint@6.0.0: + resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue-template-compiler@2.7.16: + resolution: {integrity: sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==} + + vue-tsc@1.8.27: + resolution: {integrity: sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==} + hasBin: true + peerDependencies: + typescript: '*' + + vue@3.5.26: + resolution: {integrity: sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + +snapshots: + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bufbuild/protobuf@2.10.2': {} + + '@ctrl/tinycolor@3.6.1': {} + + '@element-plus/icons-vue@2.3.2(vue@3.5.26(typescript@5.9.3))': + dependencies: + vue: 3.5.26(typescript@5.9.3) + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/utils@0.2.10': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@parcel/watcher-android-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-x64@2.5.1': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.1': + optional: true + + '@parcel/watcher-win32-arm64@2.5.1': + optional: true + + '@parcel/watcher-win32-ia32@2.5.1': + optional: true + + '@parcel/watcher-win32-x64@2.5.1': + optional: true + + '@parcel/watcher@2.5.1': + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.8 + node-addon-api: 7.1.1 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.1 + '@parcel/watcher-darwin-arm64': 2.5.1 + '@parcel/watcher-darwin-x64': 2.5.1 + '@parcel/watcher-freebsd-x64': 2.5.1 + '@parcel/watcher-linux-arm-glibc': 2.5.1 + '@parcel/watcher-linux-arm-musl': 2.5.1 + '@parcel/watcher-linux-arm64-glibc': 2.5.1 + '@parcel/watcher-linux-arm64-musl': 2.5.1 + '@parcel/watcher-linux-x64-glibc': 2.5.1 + '@parcel/watcher-linux-x64-musl': 2.5.1 + '@parcel/watcher-win32-arm64': 2.5.1 + '@parcel/watcher-win32-ia32': 2.5.1 + '@parcel/watcher-win32-x64': 2.5.1 + optional: true + + '@rollup/rollup-android-arm-eabi@4.54.0': + optional: true + + '@rollup/rollup-android-arm64@4.54.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.54.0': + optional: true + + '@rollup/rollup-darwin-x64@4.54.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.54.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.54.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.54.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.54.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.54.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.54.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.54.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.54.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.54.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.54.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.54.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.54.0': + optional: true + + '@sxzz/popperjs-es@2.11.7': {} + + '@types/estree@1.0.8': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.21 + + '@types/lodash@4.17.21': {} + + '@types/node@20.19.27': + dependencies: + undici-types: 6.21.0 + + '@types/web-bluetooth@0.0.20': {} + + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@20.19.27)(sass-embedded@1.97.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3))': + dependencies: + vite: 5.4.21(@types/node@20.19.27)(sass-embedded@1.97.1)(sass@1.97.1) + vue: 3.5.26(typescript@5.9.3) + + '@volar/language-core@1.11.1': + dependencies: + '@volar/source-map': 1.11.1 + + '@volar/source-map@1.11.1': + dependencies: + muggle-string: 0.3.1 + + '@volar/typescript@1.11.1': + dependencies: + '@volar/language-core': 1.11.1 + path-browserify: 1.0.1 + + '@vue/compiler-core@3.5.26': + dependencies: + '@babel/parser': 7.28.5 + '@vue/shared': 3.5.26 + entities: 7.0.0 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.26': + dependencies: + '@vue/compiler-core': 3.5.26 + '@vue/shared': 3.5.26 + + '@vue/compiler-sfc@3.5.26': + dependencies: + '@babel/parser': 7.28.5 + '@vue/compiler-core': 3.5.26 + '@vue/compiler-dom': 3.5.26 + '@vue/compiler-ssr': 3.5.26 + '@vue/shared': 3.5.26 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.6 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.26': + dependencies: + '@vue/compiler-dom': 3.5.26 + '@vue/shared': 3.5.26 + + '@vue/devtools-api@6.6.4': {} + + '@vue/language-core@1.8.27(typescript@5.9.3)': + dependencies: + '@volar/language-core': 1.11.1 + '@volar/source-map': 1.11.1 + '@vue/compiler-dom': 3.5.26 + '@vue/shared': 3.5.26 + computeds: 0.0.1 + minimatch: 9.0.5 + muggle-string: 0.3.1 + path-browserify: 1.0.1 + vue-template-compiler: 2.7.16 + optionalDependencies: + typescript: 5.9.3 + + '@vue/reactivity@3.5.26': + dependencies: + '@vue/shared': 3.5.26 + + '@vue/runtime-core@3.5.26': + dependencies: + '@vue/reactivity': 3.5.26 + '@vue/shared': 3.5.26 + + '@vue/runtime-dom@3.5.26': + dependencies: + '@vue/reactivity': 3.5.26 + '@vue/runtime-core': 3.5.26 + '@vue/shared': 3.5.26 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.26(vue@3.5.26(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.26 + '@vue/shared': 3.5.26 + vue: 3.5.26(typescript@5.9.3) + + '@vue/shared@3.5.26': {} + + '@vueuse/core@10.11.1(vue@3.5.26(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.20 + '@vueuse/metadata': 10.11.1 + '@vueuse/shared': 10.11.1(vue@3.5.26(typescript@5.9.3)) + vue-demi: 0.14.10(vue@3.5.26(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/metadata@10.11.1': {} + + '@vueuse/shared@10.11.1(vue@3.5.26(typescript@5.9.3))': + dependencies: + vue-demi: 0.14.10(vue@3.5.26(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + async-validator@4.2.5: {} + + asynckit@0.4.0: {} + + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + balanced-match@1.0.2: {} + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + optional: true + + buffer-builder@0.2.0: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + optional: true + + colorjs.io@0.5.2: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + computeds@0.0.1: {} + + csstype@3.2.3: {} + + dayjs@1.11.19: {} + + de-indent@1.0.2: {} + + delayed-stream@1.0.0: {} + + detect-libc@1.0.3: + optional: true + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + element-plus@2.13.0(vue@3.5.26(typescript@5.9.3)): + dependencies: + '@ctrl/tinycolor': 3.6.1 + '@element-plus/icons-vue': 2.3.2(vue@3.5.26(typescript@5.9.3)) + '@floating-ui/dom': 1.7.4 + '@popperjs/core': '@sxzz/popperjs-es@2.11.7' + '@types/lodash': 4.17.21 + '@types/lodash-es': 4.17.12 + '@vueuse/core': 10.11.1(vue@3.5.26(typescript@5.9.3)) + async-validator: 4.2.5 + dayjs: 1.11.19 + lodash: 4.17.21 + lodash-es: 4.17.22 + lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.22)(lodash@4.17.21) + memoize-one: 6.0.0 + normalize-wheel-es: 1.2.0 + vue: 3.5.26(typescript@5.9.3) + transitivePeerDependencies: + - '@vue/composition-api' + + entities@7.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + estree-walker@2.0.2: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + optional: true + + follow-redirects@1.15.11: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + gopd@1.2.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + immutable@5.1.4: {} + + is-extglob@2.1.1: + optional: true + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + optional: true + + is-number@7.0.0: + optional: true + + lodash-es@4.17.22: {} + + lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.22)(lodash@4.17.21): + dependencies: + '@types/lodash-es': 4.17.12 + lodash: 4.17.21 + lodash-es: 4.17.22 + + lodash@4.17.21: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + memoize-one@6.0.0: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + optional: true + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + muggle-string@0.3.1: {} + + nanoid@3.3.11: {} + + node-addon-api@7.1.1: + optional: true + + normalize-wheel-es@1.2.0: {} + + path-browserify@1.0.1: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: + optional: true + + pinia@2.3.1(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.26(typescript@5.9.3) + vue-demi: 0.14.10(vue@3.5.26(typescript@5.9.3)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@vue/composition-api' + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-from-env@1.1.0: {} + + readdirp@4.1.2: + optional: true + + rollup@4.54.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.54.0 + '@rollup/rollup-android-arm64': 4.54.0 + '@rollup/rollup-darwin-arm64': 4.54.0 + '@rollup/rollup-darwin-x64': 4.54.0 + '@rollup/rollup-freebsd-arm64': 4.54.0 + '@rollup/rollup-freebsd-x64': 4.54.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.54.0 + '@rollup/rollup-linux-arm-musleabihf': 4.54.0 + '@rollup/rollup-linux-arm64-gnu': 4.54.0 + '@rollup/rollup-linux-arm64-musl': 4.54.0 + '@rollup/rollup-linux-loong64-gnu': 4.54.0 + '@rollup/rollup-linux-ppc64-gnu': 4.54.0 + '@rollup/rollup-linux-riscv64-gnu': 4.54.0 + '@rollup/rollup-linux-riscv64-musl': 4.54.0 + '@rollup/rollup-linux-s390x-gnu': 4.54.0 + '@rollup/rollup-linux-x64-gnu': 4.54.0 + '@rollup/rollup-linux-x64-musl': 4.54.0 + '@rollup/rollup-openharmony-arm64': 4.54.0 + '@rollup/rollup-win32-arm64-msvc': 4.54.0 + '@rollup/rollup-win32-ia32-msvc': 4.54.0 + '@rollup/rollup-win32-x64-gnu': 4.54.0 + '@rollup/rollup-win32-x64-msvc': 4.54.0 + fsevents: 2.3.3 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + sass-embedded-all-unknown@1.97.1: + dependencies: + sass: 1.97.1 + optional: true + + sass-embedded-android-arm64@1.97.1: + optional: true + + sass-embedded-android-arm@1.97.1: + optional: true + + sass-embedded-android-riscv64@1.97.1: + optional: true + + sass-embedded-android-x64@1.97.1: + optional: true + + sass-embedded-darwin-arm64@1.97.1: + optional: true + + sass-embedded-darwin-x64@1.97.1: + optional: true + + sass-embedded-linux-arm64@1.97.1: + optional: true + + sass-embedded-linux-arm@1.97.1: + optional: true + + sass-embedded-linux-musl-arm64@1.97.1: + optional: true + + sass-embedded-linux-musl-arm@1.97.1: + optional: true + + sass-embedded-linux-musl-riscv64@1.97.1: + optional: true + + sass-embedded-linux-musl-x64@1.97.1: + optional: true + + sass-embedded-linux-riscv64@1.97.1: + optional: true + + sass-embedded-linux-x64@1.97.1: + optional: true + + sass-embedded-unknown-all@1.97.1: + dependencies: + sass: 1.97.1 + optional: true + + sass-embedded-win32-arm64@1.97.1: + optional: true + + sass-embedded-win32-x64@1.97.1: + optional: true + + sass-embedded@1.97.1: + dependencies: + '@bufbuild/protobuf': 2.10.2 + buffer-builder: 0.2.0 + colorjs.io: 0.5.2 + immutable: 5.1.4 + rxjs: 7.8.2 + supports-color: 8.1.1 + sync-child-process: 1.0.2 + varint: 6.0.0 + optionalDependencies: + sass-embedded-all-unknown: 1.97.1 + sass-embedded-android-arm: 1.97.1 + sass-embedded-android-arm64: 1.97.1 + sass-embedded-android-riscv64: 1.97.1 + sass-embedded-android-x64: 1.97.1 + sass-embedded-darwin-arm64: 1.97.1 + sass-embedded-darwin-x64: 1.97.1 + sass-embedded-linux-arm: 1.97.1 + sass-embedded-linux-arm64: 1.97.1 + sass-embedded-linux-musl-arm: 1.97.1 + sass-embedded-linux-musl-arm64: 1.97.1 + sass-embedded-linux-musl-riscv64: 1.97.1 + sass-embedded-linux-musl-x64: 1.97.1 + sass-embedded-linux-riscv64: 1.97.1 + sass-embedded-linux-x64: 1.97.1 + sass-embedded-unknown-all: 1.97.1 + sass-embedded-win32-arm64: 1.97.1 + sass-embedded-win32-x64: 1.97.1 + + sass@1.97.1: + dependencies: + chokidar: 4.0.3 + immutable: 5.1.4 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.1 + optional: true + + semver@7.7.3: {} + + source-map-js@1.2.1: {} + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + sync-child-process@1.0.2: + dependencies: + sync-message-port: 1.1.3 + + sync-message-port@1.1.3: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + optional: true + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + varint@6.0.0: {} + + vite@5.4.21(@types/node@20.19.27)(sass-embedded@1.97.1)(sass@1.97.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.54.0 + optionalDependencies: + '@types/node': 20.19.27 + fsevents: 2.3.3 + sass: 1.97.1 + sass-embedded: 1.97.1 + + vue-demi@0.14.10(vue@3.5.26(typescript@5.9.3)): + dependencies: + vue: 3.5.26(typescript@5.9.3) + + vue-router@4.6.4(vue@3.5.26(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.26(typescript@5.9.3) + + vue-template-compiler@2.7.16: + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + vue-tsc@1.8.27(typescript@5.9.3): + dependencies: + '@volar/typescript': 1.11.1 + '@vue/language-core': 1.8.27(typescript@5.9.3) + semver: 7.7.3 + typescript: 5.9.3 + + vue@3.5.26(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.26 + '@vue/compiler-sfc': 3.5.26 + '@vue/runtime-dom': 3.5.26 + '@vue/server-renderer': 3.5.26(vue@3.5.26(typescript@5.9.3)) + '@vue/shared': 3.5.26 + optionalDependencies: + typescript: 5.9.3 diff --git a/TaskShow/tsconfig.json b/TaskShow/tsconfig.json new file mode 100644 index 0000000..0e2bfc7 --- /dev/null +++ b/TaskShow/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Path alias */ + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} + diff --git a/TaskShow/tsconfig.node.json b/TaskShow/tsconfig.node.json new file mode 100644 index 0000000..41cdb7d --- /dev/null +++ b/TaskShow/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} + diff --git a/TaskShow/v0.39.1.tar.gz b/TaskShow/v0.39.1.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..d551360344310efdc7ea5b226f2e9a3391addafc GIT binary patch literal 166840 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≤Dt3py%D5B!yoG@#t)O)b`iXyy` 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�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%0Dr&# z;{Dm#(aFVkB_wYwPoK}f}ap)A=1#M#rKo*l{3e0Am!oz zSB|0Mom_4lY4?9^`TVanI)(qYO{5k1f6VYeL^HXLK$Te4;yFfaR*7j*%WSX`UtAW{ z-F)-$EXe8v8^vM_7#=X+O+y8L(lH63sbMK7+ra_N)h7n>Zfwp~U4r#N`#;b8U)BO7 zZU3jm=>K-=h5g4S(u(sx_OwVfR1%Ji{yq(M3}h!FoB$WO=PSoyC`Wk)S0V!Q05INu z7ThppiBlu(j3NqTz87paR%c3y7LDB##Ur6M8};aJmcA8{z+R5i0YQp@v(S6Bgx#GF zUX#Jc8Fz$C=ke||KQce0Q#QmQt?f+VxM#29#kp%uvZgGqc=?eiA~ zf@I{=K@cmQV(Py|(3Lm8Z zW9L5}3A5??aHO68G)ezoZ8g<)G5R)00w8#^ zQ)~oQPs#ItX>q@l(&m3dlK-e}t=TN*{{~X-`S07|^z>^80frAX=VvcqEfc5fGc>SC z7_t9ikYLSy_5xN)Jl`jC{zw4~Y|HQiocRYEHxwfkm+{{-wBVTzQ##cC{&;O|WM{U{hli^hE44ShG!w>LPV2f zxK=)VEEvE_Y2o}|(-t6g|KE`I|Lsnrxc}co%C`QW;h|wW;yR6&Vf4RbaTZr;{I>Q4 zRb|Uhd_;D~hA~BTm*k~3!@^qbZ!3!Z{YF!C{+nRSFdlc^zGF_k+FEd=&42hEo&QZu z{eP{sU{6Wk`20`lfBpy({-I+}x>x4HR)|fD`(LfsW&JO$)hgsan@9rxKR^8Ws2hk! zxnMGQe|&O&aftVFKOdc)AD^DEJdwAG@VX;IA9NjK7(Dbxx^-nhL3lMZy#6Tq;gYOj0Hf>K;A3-c+A~+kKx1yR zxiio`J@f`_8NxDlaz>8WTZj^OsNT`Z&j$bnB*?y;nnA2a_U|xV2R6qCx0QhxAF>6H zo7b_cHuKF8lbd;x_W_q7f&gn;QGXulc4_>#(U@#M!GG%$V#h7E{o@_T3@|Gd{BH$z z0>SvJRp}SYu1xhS!$D*F3P0jj_q^MKq3uj`Z@>JvK^axl5M;kQ7&VI#;7-Kw9oN8l z=7wVqZ_!Ujhc7wAxN)#7+*d~zFMjHB=*hz<6MRA#vK{lvw9wy`?ZIYC*=f4@;lh<{+F!nvbk$i4#FiH4H_}eu$+<_=8UY z{;UO`S!M9cM(~mXX$nAu!mZ#np^~9&DZ?K>~ewWAg;wNcXMX3E0 z`mlVj1`{U)k`f#@r@7Jvo~Oz^Jcc;Kco8j!aqoiJPE|!cUqj_b|!>vn#EpQCsJFeL{Eq9BTsll zPg9WwoM(85ko#P0ToU}d$FJcH9i*}f{ZSts!d0EQ>SXaepy4o#rE)L=9d_z@n>~Vv zq0tyn9AAm@MrLz&P?E-h#zxR*iDKfqNp%!WhgdGxb^+Zqeh6LHfTLa%TZ?54(PKhR zXABo4wC|Hc^l|d8TeP4$E9%c`y~5O=p}nDp(Pxb9SA1lSFefxoZsD3xu~#b2jT+_N z#U=kb1|9+#8-WcQ7iUV&bv%%Z-&X?fy_-uMXmHke`8sC{F9w16@}{&7(V`7*#17bNy>;ctI&j^uYkA}JytM6gQiPJA_98 zW%)tjvZS=|%N^erLiF|zJHp$*om;whhmms?uf=?nlG>vCyjNWThWC*}Q_j4*_wCbf zF2eeRL)1C88U5T;@)twf%&#z1zm{d(LYC@Z2pi9^K{?u@<5M$#4N_fA-1;{JQ6GKGu=7g4kc~RN5nf~ zt*dl6qfb>M9t?I%n>qbT{}40Fqa`a4Ac9Bn!?ZqgJ4~p9D7Nkk|H`oMhn1SadnOwe zizGSPj6Pvh%}O?vxqOQsj(Tvnu4qRyd5&8>M7cAIJ%`)&gctTNpQYcB3~#di+?-ZF z2k%BRB|ilGWKnbEagzfiH*-d%9p&*bK&0|Sb8}>e&M;F(0_mx%g-hL3@3826j~Nkj zn1`rTIXlHCVA~Lmyi+#jvEvr^HM#}0?ukQxr~B8#B&OAF)=6$29CPV@Z9|ZhQV9c{ zbFMDo*_(_W6Mxjxm(--CS0es>^6-$env(&+8@Vnzzf)%D?-Yc%tv@Lw1&WBq1bqx; z+e~sLRo(~B=VO3?uponY_L$8-D;pUtn1GDD{RU+boJBY)1dRv~y5%mpC^V?hn$Piz zNEYS%;ioumpPR6AVX4ofp2H=7Aif_@RSpZ0 zE#9BTvNddz&CLwVL`0mm)y-)Q+5aft7bffQOlYh#of-G+$i2Nhrkn=lkO)fu?6P2V zsbgAylOSl}saxpQHU3X1XHm{a9ih>7wHN&Xp>Gp#ZCL` zoAyfE&j-VUV{s-JY+Z>zO}02A(TULgP}n(K3=t#+GnUFZ&Fj$hWGg?|fFGxvuuPwb z;ytl8b`YIjQyM?yzd((#Li{hj4!S&<9{LHsr&8>_0Y_nd{CFU^Lu-guGeZ#sx})r= z#O$Q|6U+JhHrz!s(54x5cwyp+ufg3}`PT})P-Fs>cQ_A9fiH_B5Uztnv4|JEqF%H< z363+4g@$Y*Um=g^xdPS}y-bUU+q}se@Ai_+_?!D!JP}?eUz>~Vh0TvvVyw@WW6E4k zq`Sbv=7$S0^0$mJD>s+&r%PCBH`dD0-#v7Xy-z4sg~yc-C%E1VDU;IUka>*0^v~v0 zMYQPDvj=Hdd}hWWCvC@WALRCG=9-n+CKfi1a@3X5JoK@RZCa84LtOd^Bh(>yTaSL8 zXz__)n5HTdi+zIj6McBuZXXThgp8I@6vMzCp%8C?J?i)iI*(#p--i~E$q;oUzA3^( z#yfDxorle4Hro-o~= zGvVy(g5dQmIruQfSPLlu&tCW7#(b2$TQKAf-T94bPmJ|}2A!+E6TqxK2=?w12-iHS z`snnStBU^^Qzg?*6U_;8uu?xlRQ~^Qr-}i9Q!9HLRW+uliLjF<%|t%9UWeMXc1etK z0`}!K6|6XqF?1=IXfMJgSjV6E0CqL{|2Dl>uaJz(@u#ZEmI_odW0?nBuc}A^`=hf( zt7t!XbJRR-`#oX|i6%~_s+FdXZ0euRkfDEyqZ9n4!&@3O#gNTAQH{`Yh zaP%wcYZW70)3@qK=6=Pyp)Xb**ZdSEH-_wD(P`*&?3UuEtziuH!!I}hdIBo)j= zJnF>Z{gLgzb$?jS>cnOkO@&2m>DxWKpo?aRNFr+7P~m1;fP%4-3T!3eXFfC}c+yz&SwRcfXr>mm z|Lgi0PdPJSzvZI$|Jbno6Yp?C4Figadd%sR&))4F3Cq@;odhyl^MTbrE5SgrVtz3c z$po4rJ99AKnM1}s8c0~RHj8ASdSBSz`|c%+<4a?ag-x3tfM##?v2g#&`@J2_%b9|z zwPXMq{cfLn6QWIfz#QSxQ;FBt4c6~!avvno!dt1spZd}DMP`}gRLe1j#2O-TXQWYx zQCZ>{oo9dLf|on1aF+W{qqr{zv)eHcQ{ACB9fKKfMd$V)b@Oq15P9YoVSB27Fe_Hr z#~|L`k;5_SO-6TagOIOynVg>&CtikG=u6{8M%&m>W|kz|!sDfvCNIjIgfuLdy9{0a zqF-m}FS{r<$)63_6g|&O;2_hSzAR4XKY*f?>R_gvxHCy8NZ1(r3=W%vwVgml3af?s zTda0T&)bBsi{fO8r=Cy{zH;)Ky}nAu;W!Ajx3`p41v_ z)FjS9)v5H~FO9{8sWJOR%`(H$Xq>}e;VNUazY5-DJf*23LX`#JDt^Fd1lG*UfNsYO z;|pL%WflvxJWU^9yLFr>#$pBp&~@Vyvyj-&6-LiARRo}vIz5~6BR8s4W{-+lF!+9q z#pacx1rw`D+8fD+i3K2w8#{VTs!nz(qU`P^OEX|eWMQ|C73!AvHEdavr*uYSbAs$j zZu2pE`sHT1Q&;HAZ*Z(i!e;Hz>&v;rSi|bF{W%Ipl3IvWxZQLQ9QepK5eMWV-gKW5 z^G4d|L>M37izi3tR7tvI8od_Td1`jUggQ;f?`5HCtY-jM!moA!e$vP933@W{VQqaa zSqIq}SyJHNH?FiJ4D(8?*d(h+RbLN7#E>r(cf_JRVmgU~mdzkef>qcO(Mn0?%r4kFr*gYRb`TA7U-zHk zbaMz;-wqbk=5$iOv+qQ@R_AEgTuRH_a8>`q=uV7)b=ahUs$6qc+$_Ug0%2(Vv*O(2 zFgY3Jih0P76;aao=XvByOy4(?YVC?-LIOuz`CZ!Yd9)Zf6fMR+Y){LBXd`Ky#y;Py z$@p-0c6xSApsG?T)40~8O7FH_F^XT_6Me3AImVAeMK<}#^UL*vc47J3<*&`OHR}$P z#_fpscVe&2U7Pe6cDDqEhJWEq3xY05gF=gufjCC`MC|19XNzOKtm{=~AI<}k$_NKM z^t1eT;-*KuyocKXd3bdC-wT$$`}*Waf#h03+E)Vl;POg5Jo@0mWmN5WdnCrX9eBqTpVbK<3C~Xd0>M zhZw?!MKis)Kz98z5M2(;{diCge%XhrMLz>eTL;yuk2)MSm}>vLiI(VRGGC^~5uADB z3lxIR``L&zV)xf_$2OUAv(*Byj($5nlF;Mcl0FZ9fqPp9!e}Jce&Au~921s9zJD_j zNP|^mzjgJVv@=)?pWgUk?85^}+WXM)9utVoy%!>jmj-i9b7R2DhoWNh6%7IFJ*x%` ze(&0C>dW%ae{X{!YGg)4^3VuA%LkdWXdie zTl@sl`vgBk0u`AauFg&l<}a(HS*f*$Iaj0c^$AuBz!Jmm+AU^!Dn#Ihs(Wcf%eoXe zy!MN6A$6hp^ahFgWl9?Mv$=bM^9^w98{@6Yz%XhFsEA1cFV`M*Wke$mKYSgr@_7Pe zf}LkBaDWfn-a^H3)H-Q)rs_P}KyRyxve%&Y*|ZfGvp*@7lV`Vg-by1!H(XillE2n^ z35tvKq@;Hpg{gLg{LfQt?>sB#Q~QoEW*}g2iH9Ud^V{}}U2*~AyZL2&jiO7@aHM{C z@X)c^o*{u~Qovw0)AMVc1$6%}Ais`~Aisfd2k3}L11Mag2tXG=a{(FM6lLFRER|hY|uh%^>;wq5mGN-?V8zf6AqmucdekxagMfThpgueY8^ z)P?hVOsqw6;K%aOHErx}DofYs0itdPZgii922Vtoy6dTr^{zmf%(49PW0$+h^N?L_ zb{~p(^-WDhzAL3IuxTgr40)+r`mbtT3jl)%w3j{x!&nZV<3$tMBu0N`8EgKiC$pLk zfu613aWAbUMp+CnE*q9yR{ud}QrPkyd>2wE?kVz34tMVBwtdb@XEKl6J00x&H>fR? z-lX`OUq6^?RciBQR4EAb_BzSd2?Zzg3B~uYGa2T4|K#hhuI6?A=0RF|Qm(!3ACqZuH;%!-2zol0#{50Q zWd@C>fLP0@7hx5yL^?o)U*!>V510^x)`i!j0?Ih|f4Ybrr$+TUb`aOtJ=|l&$jlqv z?48I4&p{G&`uP(DCoh2cyFt5X%C+669?LnfId>aq2`ANP~NfD5EJ^zRqhWw;jnNw&eF_X?B|Z$^>P zYX@fteZJ`Dw3_DRuYfz-Lx13;)0fPOQfk_|n{G-RlU1Duj@&=$1b=QoMGpuhWLS_B zd3i7D$E$~J;5i;-Y7*g^8n2@k+k%oeoJD_p{t#*&v}qv+1_q3{wJiIGeRx#=K@?>H ztz%A9fE6|eM}c?qRl!zZb(JNpV>8FV=DXyoz;~peA9+dZE5Iv(kM`?%g3f8GE_K)8 zwOfVehKh0GRLXqheu^50lQxfC2;(mnvyWLPu?ye@1GLl6@f>E$1`_GCA-55{S75w> z85UBq1YO3nE&9Irn8=YMldAxB)G8j94R5)9px^!UY7zjzh|gKke*x_Z0w{<(9#*3k zXB02DrrN^JYgXLTo0SLDy`Nl5F6!?IWDc)4Kf56Lh_CCu#CYW~4?(Ofg8`U0L>BnG zwAq6##CFs9ebRL*Y&!@WnjS(+6%phs(Ar*^ejn{z0j1zS%7)`q~N-%5bwV0ztZaWZOHP7%8@&* zs2JA%->8s3hS7g{(l_V=X5pcc(}zS7@O)mb56}t8`}OSz2wA5PpYW33+M-~Zcc^K) zp7_@stBG_4>xZBgW$+}C$diy9^H}JzpxAH0;~0yxS^Yl~WTY#Zl=ss8=VmY?sMxie zMuk=n{m)-!8Y+z@hlQe1k7hq~8tMBh+12afpy?kbT9iOfa z44*;jakCKdFZ9PXcl!Sb{mrW1f6R++o-V=BkfZ6&GsQYr&p@PVaogt0Qzar)vv>Kr ze-%0kDpp9Z8Nb8dYT5^>@*J}%%8MvxKZ7_iOboKi7tbQhG5pwP){MprLz#$59Zf1! z9jhiRC3ny;*$_*PpUn_QeIxnFiG(^gjax@*JA`kAwc(*C&?p?;*D?EVEgaU?e+j4K z_=+R;6%?Nhe0nYN92OJ zN$b$iD$e%T7_yP30nx3IQWdEfl>kxFOh5l|2t?{39LcNln zB$lH`l&0_*Nf7fpDrrwCb;8&?Ep81BYv1ePk1_nl!Oo4aLAkq0EVp57e_u3lbf75>~tTWb7mGgjLW82g4L zx6%>&kMdS+bBF}lA_bJdB=4yoWwq3_odj!T5$zv(zN3S6azViMMI;Gt2`{LWx$AkB z_&R6VYcJ9H6}$pm=U#)$SdJl$`~y#2;WX=jSu-?Gi^l>@KciD%U0VD)u%0`+TJ8f0 z9K@ZX{c7M5vp#+JKx()v9{CD2U~|_;T6zuM%;A}(<)_j$R)>f8hvuJoyr!`kdB;3i zCO3ksEgG5|i4yAw6+5&R`swk??2nJwq$F%R>AdMI13(;aYV<_sVbtevxVeWe#$!l5 ztRD(9R3`TUyAKa`4X&6r0-w+bLU_FtiY?)RmL6UC)&$P?$r}NLftit4;^8x_0&%u~ zaa$^J{)%J%qhgNxwF^i<_oKF=S;Cky!v2$$tinY3FiUcKYP|t4)ie40vU73!L3L&U z9_6+_pfoq!&o+DW)WAi};E@dSLYL~wa6oe!aX>S1PD3C_hn-5|%l8ElOP+LjvfH6o zp6QH_xly?@ctdyd1^gQj&$!QWkF!2hbC*c;Aa(J$<3f}s2JBsE9+ivqVLpKPvk1UG zB)uK(R{#>3IkxXkb*{IWhrWm5LPtGg>*rMWLDX-5%;V?FlZqUIO4?~Pl^MgMOvgyY zqj=0pa0WXCQ^jBbkGKqb7nkl9M@znau4TTf#A#adyh3OGSd$f#?ronJ51i=Pj2*}q z$w}sGd~XToG`BZL`91#9clNPuM^G)8{A5{-yq=ZzVleJd|G6v21JedkuXY+>!0=;3 z0^pxO0oVV`m;bf^l{*m6^NneDduhWHW!i?7Q|r)VzF& zvXS5EIoI)U_;5mt=;L;Iex_Vh1n?mvnBi{2C5Q4(`g7ot(sk5`m!d9rQqw1_U537s zj`v~6`zG6DA%Oo?CL~?m*ej|(1l9EX1shx9%CNw8xvuO(OZdRt4A};qs_s?hH;KN* zuZu)Umg7Y>8bf7lT~A?C>mSQdTL|t*>(1G0$5QA-$Z%9XHuD)Q^|bHCKM}=b-S11U zUr2IVAV_n_(h2r_FP%8?g7;%5rEILH2w`U+_Ak{eD>zktE9$da=-mq+a`lMr`R4x;NBXP*9~ei26%gSRALY#URwOuj3)O=!jGY5l z*x8x@`P-|KgHx0?9+Yv=0=J3hp*=v9N8dhQSAX@87&3n2xR^&749Ax8-6ibzA2XDy zO7=I?ERRe{SGH#U0X1Heh42(4X)GD9+J^qT`-Qy6N8#pPtejmJ5T+P0$On(RC_Nz$Pc3m(pJ#`6o- z5<0Bns}&{xh~=B~Nc1}U>-F~Btm_EBJY=4FAaiu`2zt7{w0iqFy1ne=diy~U?FR-~ zwkN0qA6y(GKl!|L^V3YV6MG`kf0``3z_Xup^!vk2IyLaMX&UX!9t|Y)#CI~pvXPN@ zemVVTYsGtOi6RCf*QmCE+;zCU>~=~;OdDcX)+tQ^_s)WPD!ui&VI}D>%DltM-Ud9+ z=~G?x$NiHId|G|4EIj1%19E;JWywy|I_hLUw6vijU^}j`%AbH^N7oLmg3~R{43V;0 zeXYVO^M*3$`Z19Hol&@vM3@Ybj4u5y4UOWIRh%_Oh!6{8_M@#u!_iV`syyn02th*RrV6h3G>>)hLB^5mA8yQHp2iW?PMK zopmp~xoZ8{JD6TXIPC;z$isi?B9X7`{qy0_uOPz;?Mte%0ScmS?pPxrHt_(uI=SUQXenUKGG zrIB_nrGeud*o#aO16+ycWs7yMs%mb~EMkCnhFb(Sl{a&r!0%tObS_{%u&QUMaP-vY zBQxi|hv5mwh;GzEe0DHGvR-$o)-0v5QR-rmbp7CC7aR0B_Z6&P3t0JU4I4x4DSWT5 zg`NN2MUom)UqdoOHZcX^B5qP>t5BW;kaLrOHpC+BjA5%!NGIHd2O6Gba;C{@JLPsC| zNylxDedCSSKqr}!*2yn2%w`I^?o6I$C>h|82#$$9K}d;z7H&C~?O~b_@9n;3a0cmz ze(z0>xF!U;$2Tv;xw@v{cCg}NMxt2%P?nI;2O)G&GzKn#GFHpG_{qI|8DW5A(r4l7 z*;4he)j%H@RtOT+m=9Dmpgm7T<(u7f8I$!Ol&3{vjceSCd=j|Whpy)8bs;bo5lDZx z$3(1?#e6j2;vx8So-^u8E*T=Tdr(e z8w#yaK>(gPZJcLzPx$u}j37gB1w`e0F938%C3&FDE}^d~e?u5N z#TjDRe@rzUCgov&`9%LWLVguc`|4h1&<|OWz}|znyvHnamlim=EQ1c~)+UZ=-~Y|HVWFZc zVoWN+UJFJaO}!ZNy)Y#kL0)70mEMDtvnGB&AfA8llC|_|-PCA6T5=2#^a9GbjG#>m z@=`uegZmI4AidH~N4%{JM)lkq<(Yi~S4iJOtco@mZ-lwyik)B28s+kmwV2{mIL>9+ zkQ3wC-J&k|O}X4w!O3mURFbnhkAP8A2O{a=ewW1ys9~M)2ErB3F%IDy6QAo!d4MG0 z3Ij!pTI;vAZ)W$p7QzMrjmxS3o!Wzym|cWs$j)y_8`1^msNdq7tKBo~0LT$X!{!76)&_Mb|b-#-S&{!p_NCDDicHmy7 z>v1iw8T_DAOuoi0w)cHH7;j3RAtON5iY5Lvgqpz%nl-!+>7>S#ePeJm8D*K1W2-gf z1_n_#sMu;AhjjqS+usHWU&mcO9}M}r2-Qt*pP-hI<)5849X*%++~{Z*AvS>fO=+?7 zin&mp`{heTX(=ofRk^{tX#h)Du#NjaL6MagEwk5iuRMccSZAPi99X}r2N}KbV9ffR z7r-vfETA!Sepg}SsUOwvq^dShOnn5i z{sl;62VQ_in3}20mhyS(G5UaS z8?WXC-Z>-3^8L$p|G4L!Y;)oma*Djq0U`EqgLzks3WC``X%fRUvPO!N73yog zy&v?0H8yiU1Awk&rM+4s(~PZ&)mRP1)ckqejN1`zT!rCsym2g}uDe!7df%QIRjPS0 zpctoQU7)uw9)f-&&Yma>h{B&~(X82dKCiQ~TH`9E>RVO6Y|fPmPKn?eF&S3L7q|8^ zxtXH`w6frF_6&W09o>h6k!y2oOe2djaJn@{)ZN$qqKT$p+O`jVoB%=?9|3D?oo&D! z$1?L6G6FhpR@`35Ru#1|NO;@V;oTYfwpm&Db@6v^QucFK=c3ZTkHh@zxSe~>6`(!PMmber&oJ4wU_17_VLWuF1lQc66)F}kF{yr=0|LMsQ zbIH<3Klik>ebRNVzq*LbqE8)IEJ3C7KfqPbG-7qFffK&s{~79N1C_me_`3bcUnY^D zmEv&$Fe`Xl@(hH~0COuaW-V;A_8-BkQJzf%UW?^!rKLAgpMv1PrlVhgKx;^K_?8Vf zp7iL;;)<&*P*(6I;u$t+zMKFr&$izdsCzmucQGNQ^K^vab$G(&V@<)2Z%~9HrPpBz z5pShyq`;x4VpD`G7<%}|Shq#*1cc(yZ2}P($y0pEenD$2^>>AR%B4_NpN)|t>{8`p zp5gmHh?$FFgzwtupChlfxreesgPVNzk$wtYpOl!uI8z~kUU%=I?%7k9=7*!H+6D!} zT!%33Fz;2n<-ixxb_S`gHtJ?j#sS9kJMZ1m`BU5vjmNk8#Y`KrZF_6QH-k_8 zB4_GdHH>E!^O$OUZN%w)tE1;EtqpjovuBqhj|1E3+#|Svzb-^J7jhsm0?lc`-4-r} z;$68J_nSld|;tF~(3fIf)2SFRmUl&N22v$v}r!o;gS@49jO9NwHf z+`>-D4^QwG&ower7|ikQA6Pr&7%<$|w*@{(o^QE2P!Oj5V#(Mhm!>$$rtm9T1R;3) zP$^*cpx#HSaL{h?|80ak*YME0IlPYDxoVf&%Q%XR zzhoE13LFEqO|ZKGwpYkouqYmQ1?W_tpu&t7-=*Ii-u@;E$NZD0w~Ht=Sm5n09QO4kkx{r z4UcZE*}{tOpc~9C zV2kN#c}w{-yO-_3DR*niou*CbTB;F#Q|bu`)yU@V{TOXNc~;lg3(NbQ^z$-h&plyR zI5CzTrt~ph4x2Z#3qUj}&;nZ#H>2|L{&iteF6BxO{~OS`fjK4ql=}2XUC8!2H+^$( zF=4w``H!h}miG-==tgTKJJix4&n($E1SSYq1PbjI@61=Efw<#5bH!3#{4NM z?o7evn5)`+)bM@A0x;nf`3mj`1FFO8L!N~R3Dxm4j5 zwN(CWtu=!z(XnJDfun8iS)y)ZCVfEp1G2kpVUR7%50gn-BsOgpVWxYYVgRtkO?e`M*CWy6kz9<${5%M@ zWc~pa2hge<*Ur1=O-Zz4FY9VmSki;4kMQW^=*NaWU+`Oy52H0I=u=Y&n?Y&8Y}YAK zZKOXfgVDUI-t_f=0<+@4hn5Z79v&@m;0P0y;YG)ic z%Rd-VE%0*cvLb$kxbU@2SG!YI=8|?LI9V66`{r+X)b%uVhm0e`CKN(+4%Xfg9(@u% z^{Ro%;B}IKXFof!(C2ro{1Hl*1L3cts+EsjiT73Lm%?c(04Rmp=k&R&il%}OQ1i8) zaX!!?605DNps#g(T>crKusX3okd8kI+%^NUVBpIkv}n!_?+KJH6LAZfBYlGcSJ?Z0 zS}XPcKQU4bB%c6BQJOhGOL(FYbl>F-?mv`Zf^1e^+DS;)s^%h`Y&zho4dlT;frFt# zuSMvuAU*`dIwEDiiWL?ldznN54}yWXSs1yUVF;qJ3hO>fjOG9}!IP({bKKqE_}pri z#Dwb-)G4wmAN%rLuC7;EE-fZsm_XAraQ`-r7P0z9*kccqN=)@Y{0|py5it(YZ;za$ z{O$QsQL+}v1;q^2+!`qeDERIQ_HtgWYz*RxMP!smw`(RdmKjx5Xz6;Hhv1V@P%UMB zE>2>$5~4pv93Ozh^hwqUi&;@uBohqYS2Z;Y81M+U<`p5Io#2hpH4UNG7Li9F|62+q zI%D9e5km+IrKv7)ySHxmc{A1*dl6F zUDf5m+5jKdJjt}ub*eS1y^m%ivZ4)M%ASvIA|KucZEq8JF*-2Bi5eOAu@{|Qb~_|u zD@aXeqt4adnywBx4y{QBIrqDk@z>ffncXi^okta-KLbex-6i{7jV`dK^lI>nI}SrK zz@4&zspt^5kj63o-`keg;G|RGKbl5BQ(WQ@fVTVQL+XY2Vzx|K{@=%QVoWPwk^a8` z3m7B%8_yQ;8Wag|`QVxmwxO3%RSX2)d&L8xjhd`G5Id@h&rs)T`t1D=+&eEajHZ{b z?V*TT!~8xMzrJITtX*`!3)lP-y}h#AGBZC!C!~vnFR3o7gDeLsL-5rWs|B365TKJ( zbYD^7!2j;X(64HDewE;BzZzi;UgtcZZknh&CAAz^`WVme@58%Zna<;JJ zZ+JYeB-&&=wcZA_Klt#<2Egq9b^*h4JoDE={r3vp*bez;07*PBqJ)!QXkLOaeedoaNv^MAJHTjjVJ}n2Q7XRYhTSynb zXtyJTUsNa1_~WFi#AX- zuZi{w+tA%!0U1`HZT9{hB&?GukfqFHWFtFreMQa`7Jff_d#XteVn5tBEB=ngbB8?7EzV9Hj5C*yH1Y4x`B^Q`rgRf)cvtajWSKB3(~f)B~uAgdg?# zhzMf0_e*GJaKt1SK8bpKvdb{n*?P1gcuC&w@<3%6$02^s*#S*WcZX#2{Gg2D*u1Mq zMQSt~78&8v&w=cUPS?5PMoW^0gPmXTMLUA|>{au4Q>5}!>b8b9)pcNXid{f<0Bi;* zM;QZM;fcL~mZ5vVdTcFc7mi`Sb34x-NJkl;Sz;!#m)mJ0rJFIa{i4UI8Vll)gMIKu z-t~J*Gcj{EXFH7~e&rh})WYjm&ETtJZ-?a&a$*wfycuJRs3EEJ;`FZ-f z^whk>eXZRgo?LTaOz|(V4bVGP57PM2XmTohhr6m4$w%#TIS zV=)IWFYnclse2o$GO{}07tKJEr_hG&xTU$l6wa>Mbw9E7?l31+p)})Gf8_GlVQs8h ze8Fgj7V+Khf*XFdd9l3;u-v;pl^@=$4N6|%Y^1Lz87hSbQvc$E;O-SpvZjW&)~qiM zg(Z4AdO10|I5{e9gl>?EJZ7=5Y-|VQ=St~I+Mn`Dy-qsNwVHi{?bE({1d0YY|9-{Y zbbqz80nISsJ1j`5@R7{<>r}-J zlIE8K<{;iaLh7c>;n45HXRp!1Gz0_P9ji5>9EtiS0!(=v2C6qXD(uYT-H}_%DaX4` z*tpJ4w7rA}zdN%;%EPVCQ+?O4c2?8V(~pvD!A3eR@}SKfQoCk5rY51x_mCWc3eGH4+1*wl3F%}@Gm zl!u;}Sp>L0hA1+Ch+eHTa`@)$i=I+c+552wT#*JXF3C;Q^`kDvdd6u?Ppm7v@`76q z*091;6^9yyh8J?}@=BF&Vj2rJn{6;UrrB%o2xFoJH>7{*ANG;F{QJJ%1AnzR+6Ezq z5*rgoN$U=U!J{*l05_;Hg-AC4{32s*CV+}Cpv6S`VGuI|)2U1qD1Fpx)_`2sH9clOFfXC2Y z)Uy4{(o@6blp}Dvak1G2jq!YXR(U-gxIJBs09pheAFd4wj4i}FmO+igw+q=7M_$%V zVD6)ioI(tw4yn9?^M=)1Q3xTYqyibFB7+Dr&L&(*ig~9}*VGjH7Hg9%u*a_M%Srw1 zhFzbazzj&J9%^MsuNA+b>?yr@*V~O>Q>bzdmV+G@tsjuc)Cm==Xdcl!OZ! z;`XPv8YW8?E+JC#^XY+Jc{x8L(Cv6#o~l?agD)8veBTnCR|VViKdMdpw5@qV+p%;i*BzFq0UlFA%o4|FMPIH?N66B z7f)Hj_bh`G3C{O@1J=5yx?0@AT~AqqBV$?ih@j)W3kY<j_6WYAVU=X3p?IIPrvVLXtSc1_i1$JBH_U+wqQQPYEEAA z523vD52Rr|0W5I%(zS8&crp%zrc~q)#nn|bB)oPZ4Q20UKq@S#WppTD+EZ2)2Frl6 zq;qcW@_@gS4#&+bL}Dr(RKiM)?+RWxOBL~op4F#F2g=$>^vsT5yP=Ic>V;GX~xARBrTryK|zHaZT1YN(cSXC6x z0ICgqh`2OwvAbBgaEQNbe)kxszGW^tEr;wokLvmdc17{Yyw5c(%%2L8?`Msh7Z)en zf7Uz2KL|HJ40*@7K0<@)`(N$RjKyFp`F$4lGm!Br5=mJrgP?9-&7II8KvPtI+ch71 z(?x8x|0xEzOv)#{1F0{f>?r#@O+706^y#OJ_KED+tdUMw zjX4_zj(I~=)NcLiHb9)|6^#qUn8)57lpE-0cYl=fRP4_zz2P<12a0l5Tm;&Biz=8@+xZx1#pqkhHtmUT#M{I8X^?K=8a=VPPTGtJZxfPeb-=t&L_BbCUh-V=~>aEwBay zv~Ja>F`x7$%jr$&rEbXA^C0oB1!{Ct_utJyh-l3rNBoH2UN6sSny4}`;1R{q z5$Z0Iv7=~QJ?@?kcKl=KDC`Z$JNSG6sjFMr8@!Ybc+!YuJz`ipb zn8|&IbQFuBAKxjl|E{$OY87Wts1wcCzvb3d^C z-$9ss`ojMqxYzhFR%!?9&_pWsEfb>NNCQpsh#dOMDtSoqZZ{1CEpv%`T0#&WR4J3U zC1x@Uw*D5ok#T)n=_WP7%R_F|Vd zeDYh3cC1$tnqOb6;mjxZ{r0y2w+)oMn^ivKG-;o-7P6bDH^348NcafdxtTcBR=pLK zUKTU?R-Uu8oxa<5J&cGH+{o#a{y6_0uW(F&HON}@Qvf*<-b(l_0@fhDZS=q!aYbCJ zqG|xLmH7A~ZHLX6(G@TFW-W=wx6=%H$4AOBe-xm32BgS*e)!HG16uL5Rlu1Is+N{r zGWv%+Yqbi*{>n4tHctft8n5LLqNDHX1y}ToePg7pU)W5j(2;>&y+VnCpPM=FmO8^i ze$aX&xtnf@BK{67IcLLWb&}Af zAf+V+(F{7L3H&sqD&c|ni{7N~bLsed3}J1RwD|S|*c2l>Iz4y3`v4YxNgxx1-uYJd zT7B!$p#Gc+DHB>|sS`r!0DkXyk@F#h^hjMkJnSYi`$$IItoQmo#B=nmfV58*hR)i{6^aI6F2#)68NS6cDdSN z4mBn`M_5LVA8<{ZeFY-{TK}arZf-rY)?WRR7_you)v0(c6NE$%JoCQxYI!>L6o9c8 zb)R@WTcVo3;&m7fwJ7R))a|qK?Tg^=Q~KR9v3m(9QK;>G!c!%;EyBFCk-hO&}WtLl@@1?*CDTPKKdbmv5`LV84U+ z3=r^M01aeD%T1bY=Zvt^-RXfoW~KV4hj2H=kk91)ypg>udY8y@UEN@Trj3|#aWKie zX4oS=?jYD#!#37Y(5H1@#4~<|=gLIhy1$Tsy)WG2rH>h1|F~%-{ZH7cEywTQJph)) z-s?Jd)k=ZIvXrtJEog;M9(wX{3wpWbCwteNtJ_{qM(TfZQdDO%W*YJ#_$K$DMqOY* zt)6J@h*}<5%f5vguAs2A8^kWj#fQ0mC?=Nytlb|I|3~}K_zC1S^21t)&N#9uz?FJT z`jpk0JoA|ub+ovEi*E$3QZI~zi1d)ZjKDRW2@_)w)NM6v$)o}kn(|k`X2|FN3ta-D z{XsGR??2)BKXV-R7&VBI5Xdvs)wD~z|NFaCATRMX!5@{%qf zLmmI~-oo2tcywzdKR>D~^!^0H+V%@8qPu_LE$jw-l{dB{Py&%aL_VTD4-8+(dBSwL zfTruqU1F?^t&l@}mSGEgIn3c&f;zjS3q8O42_V!tgt6O&-2p_v5i3y(eXDvOJSCjH zOXB7&C_^&Pg5+A_;{4zV%8U`={Ncj7b7fok03cE(ARaaaWH&>7W%{lg7E_=k|F3k5~;guPNrkC zZ+h%x&2q9(zVdKLFBCJhlfgx^1l6LD6&8~tToS*m0IR0C35)u>LB{pT>C2;ko_9~r zUU%OfpPika9b_4jIVJ%sk~{^ZQAoNo!zdI-eq9{y%Qy>d*j?8URG&FPH!T literal 0 HcmV?d00001 diff --git a/TaskShow/vite.config.ts b/TaskShow/vite.config.ts new file mode 100644 index 0000000..0668a7d --- /dev/null +++ b/TaskShow/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + }, + server: { + port: 3000, + open: true, + proxy: { + '/api': { + target: 'http://127.0.0.1:8787', + changeOrigin: true, + // 后端路由已经包含 /api 前缀,所以直接转发,不需要 rewrite + } + } + } +}) + diff --git a/app/command/BatchUpdateTags.php b/app/command/BatchUpdateTags.php new file mode 100644 index 0000000..24703a5 --- /dev/null +++ b/app/command/BatchUpdateTags.php @@ -0,0 +1,163 @@ +addOption( + 'frequency', + 'f', + InputOption::VALUE_OPTIONAL, + '更新频率:daily/weekly/monthly', + 'daily' + ); + + $this->addOption( + 'limit', + 'l', + InputOption::VALUE_OPTIONAL, + '每次处理的最大用户数', + 1000 + ); + + $this->addOption( + 'user-ids', + 'u', + InputOption::VALUE_OPTIONAL, + '指定用户ID列表(逗号分隔),如果提供则只更新这些用户', + null + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $frequency = $input->getOption('frequency'); + $limit = (int)$input->getOption('limit'); + $userIdsOption = $input->getOption('user-ids'); + + $output->writeln("开始批量更新标签..."); + $output->writeln("更新频率: {$frequency}"); + $output->writeln("处理限制: {$limit}"); + + try { + // 获取需要更新的标签定义 + $tagDefinitionRepo = new TagDefinitionRepository(); + $tagDefinitions = $tagDefinitionRepo->newQuery() + ->where('status', 0) // 只获取启用的标签 + ->where('update_frequency', $frequency) // 匹配更新频率 + ->get(); + + if ($tagDefinitions->isEmpty()) { + $output->writeln("没有找到需要更新的标签定义(频率: {$frequency})"); + return Command::SUCCESS; + } + + $tagIds = $tagDefinitions->pluck('tag_id')->toArray(); + $output->writeln("找到 " . count($tagIds) . " 个标签需要更新"); + + // 获取需要更新的用户列表 + $userProfileRepo = new UserProfileRepository(); + if ($userIdsOption !== null) { + // 指定用户ID列表 + $userIds = array_filter(array_map('trim', explode(',', $userIdsOption))); + } else { + // 获取所有有效用户(限制数量) + $users = $userProfileRepo->newQuery() + ->where('status', 0) + ->limit($limit) + ->get(); + $userIds = $users->pluck('user_id')->toArray(); + } + + if (empty($userIds)) { + $output->writeln("没有找到需要更新的用户"); + return Command::SUCCESS; + } + + $output->writeln("找到 " . count($userIds) . " 个用户需要更新"); + + // 创建 TagService 实例 + $tagService = new TagService( + new TagDefinitionRepository(), + new UserProfileRepository(), + new UserTagRepository(), + new TagHistoryRepository(), + new SimpleRuleEngine() + ); + + // 批量更新标签 + $successCount = 0; + $errorCount = 0; + $startTime = microtime(true); + + foreach ($userIds as $index => $userId) { + try { + $tagService->calculateTags($userId, $tagIds); + $successCount++; + + if (($index + 1) % 100 === 0) { + $output->writeln("已处理: " . ($index + 1) . " / " . count($userIds)); + } + } catch (\Throwable $e) { + $errorCount++; + LoggerHelper::logError($e, [ + 'component' => 'BatchUpdateTags', + 'user_id' => $userId, + 'frequency' => $frequency, + ]); + } + } + + $duration = microtime(true) - $startTime; + + $output->writeln("批量更新完成!"); + $output->writeln("成功: {$successCount}"); + $output->writeln("失败: {$errorCount}"); + $output->writeln("耗时: " . round($duration, 2) . " 秒"); + + LoggerHelper::logBusiness('batch_tag_update_completed', [ + 'frequency' => $frequency, + 'tag_count' => count($tagIds), + 'user_count' => count($userIds), + 'success_count' => $successCount, + 'error_count' => $errorCount, + 'duration' => $duration, + ]); + + return Command::SUCCESS; + } catch (\Throwable $e) { + $output->writeln("批量更新失败: " . $e->getMessage()); + LoggerHelper::logError($e, [ + 'component' => 'BatchUpdateTags', + 'frequency' => $frequency, + ]); + return Command::FAILURE; + } + } +} + diff --git a/app/command/InitTags.php b/app/command/InitTags.php new file mode 100644 index 0000000..973c209 --- /dev/null +++ b/app/command/InitTags.php @@ -0,0 +1,28 @@ +initBasicTags(); + + echo "标签初始化完成!\n"; + } +} + diff --git a/app/controller/ConsumptionController.php b/app/controller/ConsumptionController.php new file mode 100644 index 0000000..ba882e4 --- /dev/null +++ b/app/controller/ConsumptionController.php @@ -0,0 +1,110 @@ + $request->getRealIp(), + 'user_agent' => $request->header('user-agent'), + ]); + + // 获取 JSON 请求体 + $rawBody = $request->rawBody(); + + if (empty($rawBody)) { + return ApiResponseHelper::error('请求体为空,请确保 Content-Type 为 application/json 并发送有效的 JSON 数据', 400); + } + + $payload = json_decode($rawBody, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return ApiResponseHelper::error('JSON 格式错误: ' . json_last_error_msg(), 400); + } + + // 验证用户标识:必须提供 user_id、phone_number 或 id_card 之一 + // 注意:如果手机号和身份证号都为空(但提供了user_id),仍然可以处理 + $phoneNumber = trim($payload['phone_number'] ?? ''); + $idCard = trim($payload['id_card'] ?? ''); + if (empty($payload['user_id']) && empty($phoneNumber) && empty($idCard)) { + throw new \InvalidArgumentException('缺少用户标识:必须提供 user_id、phone_number 或 id_card 之一'); + } + + // 简单手动组装 Service 依赖,后续可接入容器配置 + $tagService = new \app\service\TagService( + new \app\repository\TagDefinitionRepository(), + new \app\repository\UserProfileRepository(), + new \app\repository\UserTagRepository(), + new \app\repository\TagHistoryRepository(), + new \app\service\TagRuleEngine\SimpleRuleEngine() + ); + + $identifierService = new \app\service\IdentifierService( + new UserProfileRepository(), + new \app\service\UserPhoneService( + new \app\repository\UserPhoneRelationRepository() + ) + ); + + $service = new ConsumptionService( + new ConsumptionRecordRepository(), + new UserProfileRepository(), + $identifierService, + $tagService + ); + + $result = $service->createRecord($payload); + + // 如果返回 null,说明手机号和身份证号都为空,跳过该记录 + if ($result === null) { + LoggerHelper::logBusiness('consumption_record_skipped_no_identifier', [ + 'reason' => 'phone_number and id_card are both empty', + 'consume_time' => $payload['consume_time'] ?? null, + ]); + return ApiResponseHelper::error('记录已跳过:手机号和身份证号都为空', 400, 400); + } + + // 记录业务日志 + LoggerHelper::logBusiness('consumption_record_created', [ + 'user_id' => $result['user_id'] ?? null, + 'record_id' => $result['record_id'] ?? null, + 'amount' => $payload['amount'] ?? null, + 'phone_number' => $payload['phone_number'] ?? null, + 'id_card_provided' => !empty($payload['id_card']), + ]); + + $duration = microtime(true) - $startTime; + LoggerHelper::logPerformance('consumption_record_create', $duration, [ + 'user_id' => $result['user_id'] ?? null, + ]); + + return ApiResponseHelper::success($result); + } catch (\InvalidArgumentException $e) { + return ApiResponseHelper::error($e->getMessage(), 400); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } +} + + diff --git a/app/controller/DataCollectionTaskController.php b/app/controller/DataCollectionTaskController.php new file mode 100644 index 0000000..6a6beb5 --- /dev/null +++ b/app/controller/DataCollectionTaskController.php @@ -0,0 +1,850 @@ +post(); + + // 验证必填字段 + $requiredFields = ['name', 'data_source_id', 'database', 'target_type']; + foreach ($requiredFields as $field) { + if (empty($data[$field])) { + return ApiResponseHelper::error("缺少必填字段: {$field}", 400); + } + } + + // 验证目标类型 + if (!in_array($data['target_type'], ['consumption_record', 'generic'])) { + return ApiResponseHelper::error("目标类型必须是 consumption_record 或 generic", 400); + } + + // 如果是通用Handler,需要目标数据源配置(后端会自动处理consumption_record的配置) + if ($data['target_type'] === 'generic') { + $genericRequiredFields = ['target_data_source_id', 'target_database', 'target_collection']; + foreach ($genericRequiredFields as $field) { + if (empty($data[$field])) { + return ApiResponseHelper::error("通用Handler缺少必填字段: {$field}", 400); + } + } + } + + // 验证模式 + if (isset($data['mode']) && !in_array($data['mode'], ['batch', 'realtime'])) { + return ApiResponseHelper::error("模式必须是 batch 或 realtime", 400); + } + + // 验证集合配置 + if (empty($data['collection']) && empty($data['collections'])) { + return ApiResponseHelper::error("必须指定 collection 或 collections", 400); + } + + $service = $this->getService(); + $task = $service->createTask($data); + + return ApiResponseHelper::success($task, '任务创建成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 更新任务 + * + * PUT /api/data-collection-tasks/{task_id} + */ + public function update(Request $request): Response + { + try { + // 从请求路径中解析 task_id + $path = $request->path(); + if (preg_match('#/api/data-collection-tasks/([^/]+)#', $path, $matches)) { + $taskId = $matches[1]; + } else { + $taskId = $request->get('task_id'); + if (!$taskId) { + throw new \InvalidArgumentException('缺少 task_id 参数'); + } + } + + $data = $request->post(); + + $service = $this->getService(); + $result = $service->updateTask($taskId, $data); + + if ($result) { + return ApiResponseHelper::success(null, '任务更新成功'); + } else { + return ApiResponseHelper::error('任务更新失败', 500); + } + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 删除任务 + * + * DELETE /api/data-collection-tasks/{task_id} + */ + public function delete(Request $request): Response + { + try { + // 从请求路径中解析 task_id + $path = $request->path(); + if (preg_match('#/api/data-collection-tasks/([^/]+)#', $path, $matches)) { + $taskId = $matches[1]; + } else { + $taskId = $request->get('task_id'); + if (!$taskId) { + throw new \InvalidArgumentException('缺少 task_id 参数'); + } + } + + $service = $this->getService(); + $result = $service->deleteTask($taskId); + + if ($result) { + return ApiResponseHelper::success(null, '任务删除成功'); + } else { + return ApiResponseHelper::error('任务删除失败', 500); + } + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 启动任务 + * + * POST /api/data-collection-tasks/{task_id}/start + */ + public function start(Request $request): Response + { + try { + // 从请求路径中解析 task_id + $path = $request->path(); + if (preg_match('#/api/data-collection-tasks/([^/]+)/start#', $path, $matches)) { + $taskId = $matches[1]; + } else { + $taskId = $request->get('task_id'); + if (!$taskId) { + throw new \InvalidArgumentException('缺少 task_id 参数'); + } + } + + $service = $this->getService(); + $result = $service->startTask($taskId); + + if ($result) { + return ApiResponseHelper::success(null, '任务启动成功'); + } else { + return ApiResponseHelper::error('任务启动失败', 500); + } + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 暂停任务 + * + * POST /api/data-collection-tasks/{task_id}/pause + */ + public function pause(Request $request): Response + { + try { + // 从请求路径中解析 task_id + $path = $request->path(); + if (preg_match('#/api/data-collection-tasks/([^/]+)/pause#', $path, $matches)) { + $taskId = $matches[1]; + } else { + $taskId = $request->get('task_id'); + if (!$taskId) { + throw new \InvalidArgumentException('缺少 task_id 参数'); + } + } + + $service = $this->getService(); + $result = $service->pauseTask($taskId); + + if ($result) { + return ApiResponseHelper::success(null, '任务暂停成功'); + } else { + return ApiResponseHelper::error('任务暂停失败', 500); + } + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 停止任务 + * + * POST /api/data-collection-tasks/{task_id}/stop + */ + public function stop(Request $request): Response + { + try { + // 从请求路径中解析 task_id + $path = $request->path(); + if (preg_match('#/api/data-collection-tasks/([^/]+)/stop#', $path, $matches)) { + $taskId = $matches[1]; + } else { + $taskId = $request->get('task_id'); + if (!$taskId) { + throw new \InvalidArgumentException('缺少 task_id 参数'); + } + } + + $service = $this->getService(); + $result = $service->stopTask($taskId); + + if ($result) { + return ApiResponseHelper::success(null, '任务停止成功'); + } else { + return ApiResponseHelper::error('任务停止失败', 500); + } + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 获取任务列表 + * + * GET /api/data-collection-tasks + */ + public function list(Request $request): Response + { + try { + // 只收集非空的筛选条件 + $filters = []; + if ($request->get('status') !== null && $request->get('status') !== '') { + $filters['status'] = $request->get('status'); + } + if ($request->get('data_source_id') !== null && $request->get('data_source_id') !== '') { + $filters['data_source_id'] = $request->get('data_source_id'); + } + if ($request->get('name') !== null && $request->get('name') !== '') { + $filters['name'] = $request->get('name'); + } + + $page = (int)($request->get('page', 1)); + $pageSize = (int)($request->get('page_size', 20)); + + $service = $this->getService(); + $result = $service->getTaskList($filters, $page, $pageSize); + + return ApiResponseHelper::success($result, '查询成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 获取任务详情 + * + * GET /api/data-collection-tasks/{task_id} + */ + public function detail(Request $request): Response + { + try { + // 从请求路径中解析 task_id + $path = $request->path(); + if (preg_match('#/api/data-collection-tasks/([^/]+)$#', $path, $matches)) { + $taskId = $matches[1]; + } else { + $taskId = $request->get('task_id'); + if (!$taskId) { + throw new \InvalidArgumentException('缺少 task_id 参数'); + } + } + + $service = $this->getService(); + $task = $service->getTask($taskId); + + if ($task === null) { + return ApiResponseHelper::error('任务不存在', 404); + } + + return ApiResponseHelper::success($task, '查询成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 获取任务进度 + * + * GET /api/data-collection-tasks/{task_id}/progress + */ + public function progress(Request $request): Response + { + try { + // 从请求路径中解析 task_id + $path = $request->path(); + if (preg_match('#/api/data-collection-tasks/([^/]+)/progress#', $path, $matches)) { + $taskId = $matches[1]; + } else { + $taskId = $request->get('task_id'); + if (!$taskId) { + throw new \InvalidArgumentException('缺少 task_id 参数'); + } + } + + $service = $this->getService(); + $task = $service->getTask($taskId); + + if ($task === null) { + return ApiResponseHelper::error('任务不存在', 404); + } + + $progress = $task['progress'] ?? []; + + return ApiResponseHelper::success($progress, '查询成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 获取数据源列表 + * + * GET /api/data-collection-tasks/data-sources + */ + public function getDataSources(Request $request): Response + { + try { + // 优先使用数据库中的数据源,如果没有则使用配置文件 + $service = new \app\service\DataSourceService(new \app\repository\DataSourceRepository()); + $result = $service->getDataSourceList(['status' => 1]); + + if (!empty($result['list'])) { + // 使用数据库中的数据源 + $list = array_map(function ($ds) { + return [ + 'id' => $ds['data_source_id'], + 'name' => $ds['name'] ?? $ds['data_source_id'], // 添加名称字段 + 'type' => $ds['type'] ?? 'unknown', + 'host' => $ds['host'] ?? '', + 'port' => $ds['port'] ?? 0, + 'database' => $ds['database'] ?? '', + ]; + }, $result['list']); + } + // 注意:现在数据源配置统一从数据库读取,不再使用config('data_sources') + + // 如果数据库中没有数据源,返回空列表 + if (!isset($list)) { + $list = []; + } + + return ApiResponseHelper::success($list, '查询成功'); + } catch (\MongoDB\Driver\Exception\Exception $e) { + // MongoDB 连接错误,返回友好提示 + $errorMessage = '无法连接到 MongoDB 数据库,请检查数据库服务是否正常运行。错误详情:' . $e->getMessage(); + return ApiResponseHelper::error($errorMessage, 500); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 获取数据源的数据库列表 + * + * GET /api/data-collection-tasks/data-sources/{data_source_id}/databases + */ + public function getDatabases(Request $request, string $data_source_id): Response + { + try { + // 从数据库获取数据源配置 + $service = new \app\service\DataSourceService(new \app\repository\DataSourceRepository()); + $dataSourceConfig = $service->getDataSourceConfig($data_source_id); + + if (!$dataSourceConfig) { + return ApiResponseHelper::error('数据源不存在', 404); + } + + $dataSource = $dataSourceConfig; + + // 如果是MongoDB,连接并获取数据库列表 + if ($dataSource['type'] === 'mongodb') { + $client = $this->getMongoClient($dataSource); + $databases = $client->listDatabases(); + + $list = []; + foreach ($databases as $database) { + $dbName = $database->getName(); + // 同时返回原始名称和base64编码的ID(URL友好) + $list[] = [ + 'name' => $dbName, + 'id' => base64_encode($dbName), // URL友好的标识符 + ]; + } + + return ApiResponseHelper::success($list, '查询成功'); + } + + return ApiResponseHelper::error('不支持的数据源类型', 400); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 获取数据库的集合列表 + * + * GET /api/data-collection-tasks/data-sources/{data_source_id}/databases/{database}/collections + */ + public function getCollections(Request $request, string $data_source_id, string $database): Response + { + try { + // 解码数据库名称(支持base64编码和URL编码) + $database = $this->decodeName($database); + + // 从数据库获取数据源配置 + $service = new \app\service\DataSourceService(new \app\repository\DataSourceRepository()); + $dataSourceConfig = $service->getDataSourceConfig($data_source_id); + + if (!$dataSourceConfig) { + return ApiResponseHelper::error('数据源不存在', 404); + } + + $dataSource = $dataSourceConfig; + + // 如果是MongoDB,连接并获取集合列表 + if ($dataSource['type'] === 'mongodb') { + $client = $this->getMongoClient($dataSource); + $db = $client->selectDatabase($database); + $collections = $db->listCollections(); + + $list = []; + foreach ($collections as $collection) { + $collName = $collection->getName(); + // 同时返回原始名称和base64编码的ID(URL友好) + $list[] = [ + 'name' => $collName, + 'id' => base64_encode($collName), // URL友好的标识符 + ]; + } + + return ApiResponseHelper::success($list, '查询成功'); + } + + return ApiResponseHelper::error('不支持的数据源类型', 400); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 获取Handler的目标字段列表 + * + * GET /api/data-collection-tasks/handlers/{handler_type}/target-fields + */ + public function getHandlerTargetFields(Request $request, string $handler_type): Response + { + try { + $fields = []; + + switch ($handler_type) { + case 'consumption_record': + // 消费记录Handler的目标字段列表 + // 包含原始输入字段(推荐)和转换后字段(可选) + // Handler会自动进行转换:phone_number/id_card -> user_id, store_name -> store_id + $fields = [ + // 用户标识字段(原始输入,推荐使用) + ['name' => 'phone_number', 'label' => '手机号', 'type' => 'string', 'required' => false, 'description' => '手机号,Handler会自动解析为user_id', 'is_original' => true], + ['name' => 'id_card', 'label' => '身份证', 'type' => 'string', 'required' => false, 'description' => '身份证号,Handler会自动解析为user_id', 'is_original' => true], + // 用户ID(转换后字段,由Handler自动生成,不需要映射) + ['name' => 'user_id', 'label' => '用户ID', 'type' => 'string', 'required' => false, 'description' => '用户ID,由Handler通过phone_number/id_card自动解析生成,无需映射', 'is_original' => false, 'no_mapping' => true], + + // 门店标识字段(原始输入,推荐使用) + ['name' => 'store_name', 'label' => '门店名称', 'type' => 'string', 'required' => false, 'description' => '门店名称,Handler会自动转换为store_id', 'is_original' => true], + // 门店ID(转换后字段,由Handler自动生成,不需要映射) + ['name' => 'store_id', 'label' => '门店ID', 'type' => 'string', 'required' => false, 'description' => '门店ID,由Handler通过store_name自动转换生成,无需映射', 'is_original' => false, 'no_mapping' => true], + + // 订单标识字段(用于去重) + ['name' => 'source_order_id', 'label' => '原始订单ID', 'type' => 'string', 'required' => false, 'description' => '原始订单ID,配合店铺名称做去重唯一标识(建议配置)', 'is_original' => true], + // 注意:order_no 由系统自动生成(自动递增),不需要映射 + + // 金额和时间字段(直接字段) + ['name' => 'amount', 'label' => '消费金额', 'type' => 'float', 'required' => true, 'description' => '消费金额(必填)', 'is_original' => true], + ['name' => 'actual_amount', 'label' => '实际金额', 'type' => 'float', 'required' => true, 'description' => '实际支付金额(必填)', 'is_original' => true], + ['name' => 'consume_time', 'label' => '消费时间', 'type' => 'datetime', 'required' => true, 'description' => '消费时间,用于时间分片存储(必填)', 'is_original' => true], + + // 其他可选字段 + ['name' => 'currency', 'label' => '币种', 'type' => 'string', 'required' => false, 'description' => '币种,默认CNY(人民币)', 'is_original' => true, 'fixed_options' => true, 'options' => [['value' => 'CNY', 'label' => '人民币(CNY)'], ['value' => 'USD', 'label' => '美元(USD)']], 'default_value' => 'CNY'], + ['name' => 'status', 'label' => '记录状态', 'type' => 'int', 'required' => false, 'description' => '记录状态:0-正常,1-异常,2-已删除。默认0。需要配置源状态值到标准状态值的映射', 'is_original' => true, 'value_mapping' => true, 'target_values' => [['value' => 0, 'label' => '正常(0)'], ['value' => 1, 'label' => '异常(1)'], ['value' => 2, 'label' => '已删除(2)']], 'default_value' => 0], + ]; + break; + + case 'generic': + // 通用Handler - 没有固定的字段列表,由用户自定义 + $fields = []; + break; + + default: + return ApiResponseHelper::error("未知的Handler类型: {$handler_type}", 400); + } + + return ApiResponseHelper::success($fields, '查询成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 获取集合的字段列表(采样) + * + * GET /api/data-collection-tasks/data-sources/{data_source_id}/databases/{database}/collections/{collection}/fields + */ + public function getFields(Request $request, string $data_source_id, string $database, string $collection): Response + { + try { + // 解码数据库名称和集合名称(支持base64编码和URL编码) + $database = $this->decodeName($database); + $collection = $this->decodeName($collection); + + // 从数据库获取数据源配置 + $service = new \app\service\DataSourceService(new \app\repository\DataSourceRepository()); + $dataSourceConfig = $service->getDataSourceConfig($data_source_id); + + if (!$dataSourceConfig) { + return ApiResponseHelper::error('数据源不存在', 404); + } + + $dataSource = $dataSourceConfig; + + // 如果是MongoDB,采样获取字段 + if ($dataSource['type'] === 'mongodb') { + $client = $this->getMongoClient($dataSource); + $db = $client->selectDatabase($database); + $coll = $db->selectCollection($collection); + + // 采样一条数据 + $sample = $coll->findOne([]); + + if ($sample) { + $fields = []; + $this->extractFields($sample, '', $fields); + + return ApiResponseHelper::success($fields, '查询成功'); + } else { + return ApiResponseHelper::success([], '集合为空,无法获取字段'); + } + } + + return ApiResponseHelper::error('不支持的数据源类型', 400); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 递归提取字段 + */ + private function extractFields($data, string $prefix, array &$fields): void + { + if (is_array($data) || is_object($data)) { + foreach ($data as $key => $value) { + $fieldName = $prefix ? "{$prefix}.{$key}" : $key; + + if (is_array($value) || is_object($value)) { + if (empty($value)) { + $fields[] = [ + 'name' => $fieldName, + 'type' => 'array', + ]; + } else { + $this->extractFields($value, $fieldName, $fields); + } + } else { + $fields[] = [ + 'name' => $fieldName, + 'type' => gettype($value), + ]; + } + } + } + } + + /** + * 预览查询结果(包含lookup) + * + * POST /api/data-collection-tasks/preview-query + */ + public function previewQuery(Request $request): Response + { + try { + $data = $request->post(); + + $dataSourceId = $data['data_source_id'] ?? ''; + $database = $data['database'] ?? ''; + $collection = $data['collection'] ?? ''; + $lookups = $data['lookups'] ?? []; + $filterConditions = $data['filter_conditions'] ?? []; + $limit = (int)($data['limit'] ?? 5); // 默认预览5条 + + if (empty($dataSourceId) || empty($database) || empty($collection)) { + return ApiResponseHelper::error('缺少必要参数:data_source_id, database, collection', 400); + } + + // 获取数据源配置 + $service = new \app\service\DataSourceService(new \app\repository\DataSourceRepository()); + $dataSourceConfig = $service->getDataSourceConfig($dataSourceId); + + if (!$dataSourceConfig) { + return ApiResponseHelper::error('数据源不存在', 404); + } + + if ($dataSourceConfig['type'] !== 'mongodb') { + return ApiResponseHelper::error('目前只支持MongoDB数据源预览', 400); + } + + // 连接MongoDB + $client = $this->getMongoClient($dataSourceConfig); + $db = $client->selectDatabase($database); + $coll = $db->selectCollection($collection); + + // 构建聚合管道 + $pipeline = []; + + // 1. 添加过滤条件($match)- 必须在最前面 + $filter = $this->buildFilterForPreview($filterConditions); + if (!empty($filter)) { + $pipeline[] = ['$match' => $filter]; + } + + // 2. 添加lookup查询 + foreach ($lookups as $lookup) { + if (empty($lookup['from']) || empty($lookup['local_field']) || empty($lookup['foreign_field'])) { + continue; + } + + $lookupStage = [ + '$lookup' => [ + 'from' => $lookup['from'], + 'localField' => $lookup['local_field'], + 'foreignField' => $lookup['foreign_field'], + 'as' => $lookup['as'] ?? 'joined' + ] + ]; + + $pipeline[] = $lookupStage; + + // 如果配置了解构 + if (!empty($lookup['unwrap'])) { + $pipeline[] = [ + '$unwind' => [ + 'path' => '$' . ($lookup['as'] ?? 'joined'), + 'preserveNullAndEmptyArrays' => !empty($lookup['preserve_null']) + ] + ]; + } + } + + // 3. 限制返回数量 + $pipeline[] = ['$limit' => $limit]; + + // 执行聚合查询 + $cursor = $coll->aggregate($pipeline); + $results = []; + $fields = []; + + foreach ($cursor as $doc) { + $docArray = $this->convertMongoDocumentToArray($doc); + $results[] = $docArray; + + // 提取字段 + $this->extractFields($docArray, '', $fields); + } + + // 去重字段 + $uniqueFields = []; + $fieldMap = []; + foreach ($fields as $field) { + if (!isset($fieldMap[$field['name']])) { + $fieldMap[$field['name']] = true; + $uniqueFields[] = $field; + } + } + + return ApiResponseHelper::success([ + 'fields' => $uniqueFields, + 'data' => $results, + 'count' => count($results) + ], '预览成功'); + + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 将MongoDB文档转换为数组 + */ + private function convertMongoDocumentToArray($document): array + { + if (is_array($document)) { + return $document; + } + + if (is_object($document)) { + $array = []; + foreach ($document as $key => $value) { + if ($value instanceof \MongoDB\BSON\UTCDateTime) { + $array[$key] = $value->toDateTime()->format('Y-m-d H:i:s'); + } elseif (is_object($value) && method_exists($value, '__toString')) { + $array[$key] = (string)$value; + } elseif (is_array($value) || is_object($value)) { + $array[$key] = $this->convertMongoDocumentToArray($value); + } else { + $array[$key] = $value; + } + } + return $array; + } + + return []; + } + + /** + * 构建过滤条件(用于预览查询) + * + * @param array $filterConditions 过滤条件列表 + * @return array MongoDB查询过滤器 + */ + private function buildFilterForPreview(array $filterConditions): array + { + $filter = []; + + foreach ($filterConditions as $condition) { + $field = $condition['field'] ?? ''; + $operator = $condition['operator'] ?? 'eq'; + $value = $condition['value'] ?? null; + + if (empty($field)) { + continue; + } + + // 处理值的类型转换 + if ($value !== null && $value !== '') { + // 尝试转换为数字(如果是数字字符串) + if (is_numeric($value)) { + // 判断是整数还是浮点数 + if (strpos($value, '.') !== false) { + $value = (float)$value; + } else { + $value = (int)$value; + } + } + } + + switch ($operator) { + case 'eq': + $filter[$field] = $value; + break; + case 'ne': + $filter[$field] = ['$ne' => $value]; + break; + case 'gt': + $filter[$field] = ['$gt' => $value]; + break; + case 'gte': + $filter[$field] = ['$gte' => $value]; + break; + case 'lt': + $filter[$field] = ['$lt' => $value]; + break; + case 'lte': + $filter[$field] = ['$lte' => $value]; + break; + case 'in': + // in操作符的值应该是数组 + $valueArray = is_array($value) ? $value : explode(',', (string)$value); + $filter[$field] = ['$in' => $valueArray]; + break; + case 'nin': + // nin操作符的值应该是数组 + $valueArray = is_array($value) ? $value : explode(',', (string)$value); + $filter[$field] = ['$nin' => $valueArray]; + break; + } + } + + return $filter; + } + + /** + * 解码数据库或集合名称(支持base64编码和URL编码) + * + * @param string $name 编码后的名称 + * @return string 解码后的名称 + */ + private function decodeName(string $name): string + { + // 尝试base64解码(如果前端使用的是编码后的ID) + // 检查是否可能是base64编码(只包含base64字符且长度合理) + if (preg_match('/^[A-Za-z0-9+\/]*={0,2}$/', $name) && strlen($name) > 0) { + $decoded = @base64_decode($name, true); + if ($decoded !== false && $decoded !== '') { + // 解码成功,使用解码后的值 + return $decoded; + } + } + + // 不是base64格式或解码失败,使用URL解码(处理中文等特殊字符) + return rawurldecode($name); + } + + /** + * 获取MongoDB客户端 + */ + private function getMongoClient(array $config): \MongoDB\Client + { + $host = $config['host'] ?? ''; + $port = (int)($config['port'] ?? 27017); + $username = $config['username'] ?? ''; + $password = $config['password'] ?? ''; + $authSource = $config['auth_source'] ?? 'admin'; + + if (!empty($username) && !empty($password)) { + $dsn = "mongodb://{$username}:{$password}@{$host}:{$port}/{$authSource}"; + } else { + $dsn = "mongodb://{$host}:{$port}"; + } + + return new \MongoDB\Client($dsn, $config['options'] ?? []); + } +} + diff --git a/app/controller/DataSourceController.php b/app/controller/DataSourceController.php new file mode 100644 index 0000000..3f1ef36 --- /dev/null +++ b/app/controller/DataSourceController.php @@ -0,0 +1,173 @@ +getService(); + $filters = [ + 'type' => $request->get('type'), + 'status' => $request->get('status'), + 'name' => $request->get('name'), + 'page' => $request->get('page', 1), + 'page_size' => $request->get('page_size', 20), + ]; + + $result = $service->getDataSourceList($filters); + + return ApiResponseHelper::success([ + 'data_sources' => $result['list'], + 'total' => $result['total'], + 'page' => (int)$filters['page'], + 'page_size' => (int)$filters['page_size'], + ], '查询成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 获取数据源详情 + * + * GET /api/data-sources/{data_source_id} + */ + public function detail(Request $request, string $data_source_id): Response + { + try { + $service = $this->getService(); + $dataSource = $service->getDataSourceDetail($data_source_id); + + if (!$dataSource) { + return ApiResponseHelper::error('数据源不存在', 404); + } + + return ApiResponseHelper::success($dataSource, '查询成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 创建数据源 + * + * POST /api/data-sources + */ + public function create(Request $request): Response + { + try { + $data = $request->post(); + + $service = $this->getService(); + $dataSource = $service->createDataSource($data); + + // 不返回密码 + $result = $dataSource->toArray(); + unset($result['password']); + + return ApiResponseHelper::success($result, '创建成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 更新数据源 + * + * PUT /api/data-sources/{data_source_id} + */ + public function update(Request $request, string $data_source_id): Response + { + try { + $data = $request->post(); + + $service = $this->getService(); + $result = $service->updateDataSource($data_source_id, $data); + + if ($result) { + return ApiResponseHelper::success(null, '更新成功'); + } else { + return ApiResponseHelper::error('更新失败', 500); + } + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 删除数据源 + * + * DELETE /api/data-sources/{data_source_id} + */ + public function delete(Request $request, string $data_source_id): Response + { + try { + $service = $this->getService(); + $result = $service->deleteDataSource($data_source_id); + + if ($result) { + return ApiResponseHelper::success(null, '删除成功'); + } else { + return ApiResponseHelper::error('删除失败', 500); + } + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 测试数据源连接 + * + * POST /api/data-sources/test-connection + */ + public function testConnection(Request $request): Response + { + try { + $data = $request->post(); + + // 验证必填字段 + $requiredFields = ['type', 'host', 'port', 'database']; + foreach ($requiredFields as $field) { + if (empty($data[$field])) { + return ApiResponseHelper::error("缺少必填字段: {$field}", 400); + } + } + + $service = $this->getService(); + $connected = $service->testConnection($data); + + if ($connected) { + return ApiResponseHelper::success(['connected' => true], '连接成功'); + } else { + return ApiResponseHelper::error('连接失败,请检查配置', 400); + } + } catch (\Throwable $e) { + return ApiResponseHelper::error('连接测试失败: ' . $e->getMessage(), 400); + } + } +} + diff --git a/app/controller/DatabaseSyncController.php b/app/controller/DatabaseSyncController.php new file mode 100644 index 0000000..c1ae814 --- /dev/null +++ b/app/controller/DatabaseSyncController.php @@ -0,0 +1,455 @@ +404 Not Found

看板页面不存在

', 404) + ->withHeader('Content-Type', 'text/html; charset=utf-8'); + } + + $html = file_get_contents($htmlPath); + return response($html)->withHeader('Content-Type', 'text/html; charset=utf-8'); + } + + /** + * 获取同步进度 + * + * GET /api/database-sync/progress + */ + public function progress(Request $request): Response + { + try { + // 创建 DatabaseSyncService 实例(传递最小配置,仅用于读取进度) + // 注意:数据库同步功能已迁移到 data_collection_tasks.php,这里仅用于查询进度 + $minimalConfig = [ + 'source' => ['host' => '', 'port' => 27017], // 占位符,不会实际连接 + 'target' => ['host' => '', 'port' => 27017], // 占位符,不会实际连接 + 'sync' => [], + 'monitoring' => [], + ]; + $syncService = new DatabaseSyncService($minimalConfig); + // 加载最新进度 + $syncService->loadProgress(); + $progress = $syncService->getProgress(); + + // 获取多进程状态信息 + $workerStatus = $this->getWorkerStatus(); + $progress['worker_status'] = $workerStatus; + + // 获取数据库连接状态 + $connectionStatus = $this->getConnectionStatus(); + $progress['connection_status'] = $connectionStatus; + + // 获取数据库列表信息(已完成和待同步) + $databaseList = $this->getDatabaseList($syncService); + $progress['database_list'] = $databaseList; + + // 检查进度文件最后修改时间 + $runtimePath = function_exists('runtime_path') ? runtime_path() : (config('app.runtime_path', base_path() . DIRECTORY_SEPARATOR . 'runtime')); + $progressFile = $runtimePath . DIRECTORY_SEPARATOR . 'database_sync_progress.json'; + if (file_exists($progressFile)) { + $fileTime = filemtime($progressFile); + $progress['progress_file_last_modified'] = date('Y-m-d H:i:s', $fileTime); + $progress['progress_file_age_seconds'] = time() - $fileTime; + } else { + $progress['progress_file_last_modified'] = null; + $progress['progress_file_age_seconds'] = null; + } + + // 如果状态是idle且没有开始时间,尝试检查是否真的在运行 + if ($progress['status'] === 'idle' && $progress['time']['start_time'] === null) { + if (!file_exists($progressFile)) { + $progress['hint'] = '请执行: php start.php status 查看 data_sync_scheduler 进程是否运行(数据库同步任务由 data_sync_scheduler 管理)'; + } + } + + return ApiResponseHelper::success($progress, '同步进度查询成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 获取 Worker 进程状态信息 + * + * @return array Worker 状态信息 + */ + private function getWorkerStatus(): array + { + $status = [ + 'total_workers' => 0, + 'active_workers' => 0, + 'workers' => [], + ]; + + try { + // 从配置中获取 Worker 数量 + $processConfig = config('process.data_sync_scheduler', []); + $totalWorkers = (int)($processConfig['count'] ?? 10); + $status['total_workers'] = $totalWorkers; + + // 检查进度文件中的 checkpoints,推断每个 Worker 处理的数据库 + $runtimePath = function_exists('runtime_path') ? runtime_path() : (config('app.runtime_path', base_path() . DIRECTORY_SEPARATOR . 'runtime')); + $progressFile = $runtimePath . DIRECTORY_SEPARATOR . 'database_sync_progress.json'; + + if (file_exists($progressFile)) { + // 使用文件锁读取,避免并发问题 + $fp = fopen($progressFile, 'r'); + if ($fp && flock($fp, LOCK_SH)) { + try { + $content = stream_get_contents($fp); + $progressData = json_decode($content, true); + + if ($progressData && isset($progressData['checkpoints'])) { + $checkpoints = $progressData['checkpoints']; + $databases = array_keys($checkpoints); + + // 根据数据库分配推断每个 Worker 的状态(使用取模分配) + foreach ($databases as $index => $database) { + $workerId = $index % $totalWorkers; + if (!isset($status['workers'][$workerId])) { + $status['workers'][$workerId] = [ + 'worker_id' => $workerId, + 'databases' => [], + 'collections' => 0, + 'documents_processed' => 0, + 'status' => 'active', + ]; + } + + $status['workers'][$workerId]['databases'][] = $database; + + // 统计该 Worker 处理的集合和文档数 + if (isset($checkpoints[$database]) && is_array($checkpoints[$database])) { + foreach ($checkpoints[$database] as $collection => $checkpoint) { + $status['workers'][$workerId]['collections']++; + if (isset($checkpoint['processed'])) { + $status['workers'][$workerId]['documents_processed'] += (int)$checkpoint['processed']; + } + } + } + } + + $status['active_workers'] = count($status['workers']); + + // 将 workers 数组转换为索引数组(便于前端遍历) + $status['workers'] = array_values($status['workers']); + } + } finally { + flock($fp, LOCK_UN); + fclose($fp); + } + } else { + if ($fp) { + fclose($fp); + } + } + } + + // 如果没有活动的 Worker,但配置了 Worker 数量,显示所有 Worker(等待状态) + if ($status['active_workers'] === 0 && $status['total_workers'] > 0) { + // 创建所有 Worker 的占位信息 + for ($i = 0; $i < $totalWorkers; $i++) { + $status['workers'][] = [ + 'worker_id' => $i, + 'databases' => [], + 'collections' => 0, + 'documents_processed' => 0, + 'status' => 'waiting', // 等待状态 + ]; + } + $status['message'] = '所有 Worker 处于等待状态,同步尚未开始或进度文件为空'; + } elseif ($status['total_workers'] === 0) { + $status['message'] = '未配置 Worker 数量,请检查 config/process.php'; + } + + } catch (\Throwable $e) { + $status['error'] = '获取 Worker 状态失败: ' . $e->getMessage(); + } + + return $status; + } + + /** + * 获取同步统计信息 + * + * GET /api/database-sync/stats + */ + public function stats(Request $request): Response + { + try { + // 创建 DatabaseSyncService 实例(传递最小配置,仅用于读取统计) + $minimalConfig = [ + 'source' => ['host' => '', 'port' => 27017], + 'target' => ['host' => '', 'port' => 27017], + ]; + $syncService = new DatabaseSyncService($minimalConfig); + $stats = $syncService->getStats(); + + return ApiResponseHelper::success($stats, '统计信息查询成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 重置同步进度 + * + * POST /api/database-sync/reset + */ + public function reset(Request $request): Response + { + try { + // 创建 DatabaseSyncService 实例(传递最小配置,仅用于重置进度) + $minimalConfig = [ + 'source' => ['host' => '', 'port' => 27017], + 'target' => ['host' => '', 'port' => 27017], + ]; + $syncService = new DatabaseSyncService($minimalConfig); + $syncService->resetProgress(); + + return ApiResponseHelper::success(null, '同步进度已重置'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 跳过错误数据库,继续同步 + * + * POST /api/database-sync/skip-error + */ + public function skipError(Request $request): Response + { + try { + // 创建 DatabaseSyncService 实例(传递最小配置,仅用于跳过错误) + $minimalConfig = [ + 'source' => ['host' => '', 'port' => 27017], + 'target' => ['host' => '', 'port' => 27017], + ]; + $syncService = new DatabaseSyncService($minimalConfig); + $syncService->loadProgress(); + + $skipped = $syncService->skipErrorDatabase(); + + if ($skipped) { + return ApiResponseHelper::success(null, '已跳过错误数据库,将继续同步下一个数据库'); + } else { + return ApiResponseHelper::error('当前没有错误数据库需要跳过', 400); + } + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 获取数据库连接状态 + * + * @return array 连接状态信息 + */ + private function getConnectionStatus(): array + { + $status = [ + 'source' => [ + 'connected' => false, + 'host' => '', + 'port' => 0, + 'error' => null, + ], + 'target' => [ + 'connected' => false, + 'host' => '', + 'port' => 0, + 'error' => null, + ], + ]; + + try { + // 从数据库获取数据源配置 + $dataSourceService = new \app\service\DataSourceService(new \app\repository\DataSourceRepository()); + + // 检查源数据库连接 + $sourceDataSourceId = 'kr_mongodb'; // 默认源数据库ID,可以从任务配置中获取 + $sourceConfig = $dataSourceService->getDataSourceConfigById($sourceDataSourceId); + + if ($sourceConfig) { + $status['source']['host'] = $sourceConfig['host'] ?? ''; + $status['source']['port'] = (int)($sourceConfig['port'] ?? 27017); + + // 尝试连接源数据库 + try { + $sourceDsn = $this->buildDsn($sourceConfig); + $sourceClient = new \MongoDB\Client($sourceDsn, $sourceConfig['options'] ?? []); + // 执行一个简单的命令来测试连接 + $sourceClient->selectDatabase('admin')->command(['ping' => 1]); + $status['source']['connected'] = true; + } catch (\Throwable $e) { + $status['source']['connected'] = false; + $status['source']['error'] = $e->getMessage(); + } + } else { + $status['source']['error'] = '源数据库配置不存在'; + } + + // 检查目标数据库连接 + $targetDataSourceId = 'sync_mongodb'; // 默认目标数据库ID,可以从任务配置中获取 + $targetConfig = $dataSourceService->getDataSourceConfigById($targetDataSourceId); + + if ($targetConfig) { + $status['target']['host'] = $targetConfig['host'] ?? ''; + $status['target']['port'] = (int)($targetConfig['port'] ?? 27017); + + // 尝试连接目标数据库 + try { + $targetDsn = $this->buildDsn($targetConfig); + $targetClient = new \MongoDB\Client($targetDsn, $targetConfig['options'] ?? []); + // 执行一个简单的命令来测试连接 + $targetClient->selectDatabase('admin')->command(['ping' => 1]); + $status['target']['connected'] = true; + } catch (\Throwable $e) { + $status['target']['connected'] = false; + $status['target']['error'] = $e->getMessage(); + } + } else { + $status['target']['error'] = '目标数据库配置不存在'; + } + + } catch (\Throwable $e) { + $status['error'] = '检查连接状态失败: ' . $e->getMessage(); + } + + return $status; + } + + /** + * 构建 MongoDB DSN + * + * @param array $config 数据库配置 + * @return string DSN 字符串 + */ + private function buildDsn(array $config): string + { + $host = $config['host'] ?? ''; + $port = (int)($config['port'] ?? 27017); + + $dsn = 'mongodb://'; + if (!empty($config['username']) && !empty($config['password'])) { + $dsn .= urlencode($config['username']) . ':' . urlencode($config['password']) . '@'; + } + $dsn .= $host . ':' . $port; + if (!empty($config['auth_source'])) { + $dsn .= '/?authSource=' . urlencode($config['auth_source']); + } + return $dsn; + } + + /** + * 获取数据库列表信息(已完成和待同步) + * + * @param DatabaseSyncService $syncService 同步服务实例 + * @return array 数据库列表信息 + */ + private function getDatabaseList(DatabaseSyncService $syncService): array + { + $list = [ + 'completed' => [], + 'pending' => [], + 'processing' => [], + ]; + + try { + $runtimePath = function_exists('runtime_path') ? runtime_path() : (config('app.runtime_path', base_path() . DIRECTORY_SEPARATOR . 'runtime')); + $progressFile = $runtimePath . DIRECTORY_SEPARATOR . 'database_sync_progress.json'; + + if (file_exists($progressFile)) { + $fp = fopen($progressFile, 'r'); + if ($fp && flock($fp, LOCK_SH)) { + try { + $content = stream_get_contents($fp); + $progressData = json_decode($content, true); + + if ($progressData) { + $checkpoints = $progressData['checkpoints'] ?? []; + $collectionsSnapshot = $progressData['collections_snapshot'] ?? []; + $currentDatabase = $progressData['current_database'] ?? null; + + // 获取所有数据库名称 + $allDatabases = array_keys($collectionsSnapshot); + + foreach ($allDatabases as $database) { + $dbCheckpoints = $checkpoints[$database] ?? []; + + // 检查该数据库的所有集合是否都已完成 + $collections = $collectionsSnapshot[$database] ?? []; + $allCompleted = true; + $hasData = false; + + foreach ($collections as $collection) { + $checkpoint = $dbCheckpoints[$collection] ?? null; + if ($checkpoint) { + $hasData = true; + if (!($checkpoint['completed'] ?? false)) { + $allCompleted = false; + break; + } + } else { + $allCompleted = false; + } + } + + if ($database === $currentDatabase) { + $list['processing'][] = [ + 'name' => $database, + 'collections' => count($collections), + 'collections_completed' => count(array_filter($dbCheckpoints, fn($cp) => $cp['completed'] ?? false)), + ]; + } elseif ($allCompleted && $hasData) { + $list['completed'][] = [ + 'name' => $database, + 'collections' => count($collections), + ]; + } else { + $list['pending'][] = [ + 'name' => $database, + 'collections' => count($collections), + ]; + } + } + } + } finally { + flock($fp, LOCK_UN); + fclose($fp); + } + } else { + if ($fp) { + fclose($fp); + } + } + } + } catch (\Throwable $e) { + // 忽略错误,返回空列表 + } + + return $list; + } +} diff --git a/app/controller/PersonMergeController.php b/app/controller/PersonMergeController.php new file mode 100644 index 0000000..6173e0c --- /dev/null +++ b/app/controller/PersonMergeController.php @@ -0,0 +1,169 @@ +rawBody(); + + if (empty($rawBody)) { + return ApiResponseHelper::error('请求体为空,请确保 Content-Type 为 application/json 并发送有效的 JSON 数据', 400); + } + + $body = json_decode($rawBody, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return ApiResponseHelper::error('JSON 格式错误: ' . json_last_error_msg(), 400); + } + + // 验证必填字段 + if (empty($body['phone_number'])) { + throw new \InvalidArgumentException('缺少必填字段:phone_number'); + } + if (empty($body['id_card'])) { + throw new \InvalidArgumentException('缺少必填字段:id_card'); + } + + $phoneNumber = (string)$body['phone_number']; + $idCard = (string)$body['id_card']; + + // 创建服务实例 + $mergeService = new PersonMergeService( + new UserProfileRepository(), + new UserTagRepository(), + new UserPhoneService( + new UserPhoneRelationRepository() + ), + new TagService( + new TagDefinitionRepository(), + new UserProfileRepository(), + new UserTagRepository(), + new TagHistoryRepository(), + new SimpleRuleEngine() + ) + ); + + // 执行合并 + $formalUserId = $mergeService->mergePhoneToIdCard($phoneNumber, $idCard); + + LoggerHelper::logBusiness('person_merge_phone_to_id_card', [ + 'phone_number' => $phoneNumber, + 'id_card_provided' => true, + 'formal_user_id' => $formalUserId, + ]); + + return ApiResponseHelper::success([ + 'phone_number' => $phoneNumber, + 'formal_user_id' => $formalUserId, + 'message' => '身份合并成功,标签已重新计算', + ]); + } catch (\InvalidArgumentException $e) { + return ApiResponseHelper::error($e->getMessage(), 400); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 合并临时人到正式人 + * + * POST /api/person-merge/temporary-to-formal + */ + public function mergeTemporaryToFormal(Request $request): Response + { + try { + LoggerHelper::logRequest('POST', '/api/person-merge/temporary-to-formal'); + + $rawBody = $request->rawBody(); + + if (empty($rawBody)) { + return ApiResponseHelper::error('请求体为空,请确保 Content-Type 为 application/json 并发送有效的 JSON 数据', 400); + } + + $body = json_decode($rawBody, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return ApiResponseHelper::error('JSON 格式错误: ' . json_last_error_msg(), 400); + } + + // 验证必填字段 + if (empty($body['user_id'])) { + throw new \InvalidArgumentException('缺少必填字段:user_id'); + } + if (empty($body['id_card'])) { + throw new \InvalidArgumentException('缺少必填字段:id_card'); + } + + $tempUserId = (string)$body['user_id']; + $idCard = (string)$body['id_card']; + + // 创建服务实例 + $mergeService = new PersonMergeService( + new UserProfileRepository(), + new UserTagRepository(), + new UserPhoneService( + new UserPhoneRelationRepository() + ), + new TagService( + new TagDefinitionRepository(), + new UserProfileRepository(), + new UserTagRepository(), + new TagHistoryRepository(), + new SimpleRuleEngine() + ) + ); + + // 执行合并 + $formalUserId = $mergeService->mergeTemporaryToFormal($tempUserId, $idCard); + + LoggerHelper::logBusiness('person_merge_temporary_to_formal', [ + 'temp_user_id' => $tempUserId, + 'formal_user_id' => $formalUserId, + ]); + + return ApiResponseHelper::success([ + 'temp_user_id' => $tempUserId, + 'formal_user_id' => $formalUserId, + 'message' => '临时人已转为正式人,标签已重新计算', + ]); + } catch (\InvalidArgumentException $e) { + return ApiResponseHelper::error($e->getMessage(), 400); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } +} + diff --git a/app/controller/TagCohortController.php b/app/controller/TagCohortController.php new file mode 100644 index 0000000..217c007 --- /dev/null +++ b/app/controller/TagCohortController.php @@ -0,0 +1,308 @@ +get('page') ?? 1); + $pageSize = (int)($request->get('page_size') ?? 20); + + if ($page < 1) { + $page = 1; + } + if ($pageSize < 1 || $pageSize > 100) { + $pageSize = 20; + } + + $cohortRepo = new TagCohortRepository(); + + $total = $cohortRepo->newQuery()->count(); + + $cohorts = $cohortRepo->newQuery() + ->orderBy('created_at', 'desc') + ->skip(($page - 1) * $pageSize) + ->take($pageSize) + ->get(); + + $result = []; + foreach ($cohorts as $cohort) { + $result[] = [ + 'cohort_id' => $cohort->cohort_id, + 'name' => $cohort->name, + 'user_count' => $cohort->user_count ?? 0, + 'created_at' => $cohort->created_at ? $cohort->created_at->format('Y-m-d H:i:s') : null, + ]; + } + + LoggerHelper::logBusiness('get_tag_cohort_list', [ + 'total' => $total, + 'page' => $page, + ]); + + return ApiResponseHelper::success([ + 'cohorts' => $result, + 'total' => $total, + 'page' => $page, + 'page_size' => $pageSize, + ]); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 获取人群快照详情 + * + * GET /api/tag-cohorts/{cohort_id} + */ + public function detail(Request $request, string $cohortId): Response + { + try { + LoggerHelper::logRequest('GET', "/api/tag-cohorts/{$cohortId}"); + + $cohortRepo = new TagCohortRepository(); + $cohort = $cohortRepo->newQuery()->where('cohort_id', $cohortId)->first(); + + if (!$cohort) { + return ApiResponseHelper::error('人群快照不存在', 404, 404); + } + + $result = [ + 'cohort_id' => $cohort->cohort_id, + 'name' => $cohort->name, + 'description' => $cohort->description ?? '', + 'conditions' => $cohort->conditions ?? [], + 'logic' => $cohort->logic ?? 'AND', + 'user_ids' => $cohort->user_ids ?? [], + 'user_count' => $cohort->user_count ?? 0, + 'created_at' => $cohort->created_at ? $cohort->created_at->format('Y-m-d H:i:s') : null, + ]; + + LoggerHelper::logBusiness('get_tag_cohort_detail', [ + 'cohort_id' => $cohortId, + ]); + + return ApiResponseHelper::success($result); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 创建人群快照 + * + * POST /api/tag-cohorts + */ + public function create(Request $request): Response + { + try { + LoggerHelper::logRequest('POST', '/api/tag-cohorts'); + + $rawBody = $request->rawBody(); + + if (empty($rawBody)) { + return ApiResponseHelper::error('请求体为空,请确保 Content-Type 为 application/json 并发送有效的 JSON 数据', 400); + } + + $body = json_decode($rawBody, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return ApiResponseHelper::error('JSON 格式错误: ' . json_last_error_msg(), 400); + } + + // 验证必填字段 + if (empty($body['name'])) { + throw new \InvalidArgumentException('缺少必填字段:name'); + } + + if (empty($body['conditions']) || !is_array($body['conditions'])) { + throw new \InvalidArgumentException('缺少必填字段:conditions(必须为数组)'); + } + + if (empty($body['user_ids']) || !is_array($body['user_ids'])) { + throw new \InvalidArgumentException('缺少必填字段:user_ids(必须为数组)'); + } + + // 使用标签筛选服务获取用户列表 + $tagService = new TagService( + new TagDefinitionRepository(), + new UserProfileRepository(), + new UserTagRepository(), + new TagHistoryRepository(), + new SimpleRuleEngine() + ); + + $conditions = $body['conditions']; + $logic = $body['logic'] ?? 'AND'; + + // 筛选用户(获取所有用户,不包含用户信息) + $filterResult = $tagService->filterUsersByTags( + $conditions, + $logic, + 1, + 10000, // 最多获取10000个用户 + false + ); + + // 从返回结果中提取用户ID + $userIds = []; + if (isset($filterResult['users']) && is_array($filterResult['users'])) { + foreach ($filterResult['users'] as $user) { + if (isset($user['user_id'])) { + $userIds[] = $user['user_id']; + } elseif (is_string($user)) { + // 如果直接返回的是用户ID字符串 + $userIds[] = $user; + } + } + } + + // 如果用户提供了 user_ids,使用提供的列表(优先级更高) + if (!empty($body['user_ids']) && is_array($body['user_ids'])) { + $userIds = $body['user_ids']; + } + + // 创建人群快照 + $cohortRepo = new TagCohortRepository(); + $cohort = new TagCohortRepository(); + $cohort->cohort_id = Uuid::uuid4()->toString(); + $cohort->name = $body['name']; + $cohort->description = $body['description'] ?? ''; + $cohort->conditions = $conditions; + $cohort->logic = $logic; + $cohort->user_ids = $userIds; + $cohort->user_count = count($userIds); + $cohort->created_by = $body['created_by'] ?? 'system'; + $cohort->created_at = new \DateTime(); + $cohort->updated_at = new \DateTime(); + $cohort->save(); + + LoggerHelper::logBusiness('create_tag_cohort', [ + 'cohort_id' => $cohort->cohort_id, + 'name' => $cohort->name, + 'user_count' => $cohort->user_count, + ]); + + return ApiResponseHelper::success([ + 'cohort_id' => $cohort->cohort_id, + 'name' => $cohort->name, + 'user_count' => $cohort->user_count, + ], '人群快照创建成功'); + } catch (\InvalidArgumentException $e) { + return ApiResponseHelper::error($e->getMessage(), 400); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 删除人群快照 + * + * DELETE /api/tag-cohorts/{cohort_id} + */ + public function delete(Request $request, string $cohortId): Response + { + try { + LoggerHelper::logRequest('DELETE', "/api/tag-cohorts/{$cohortId}"); + + $cohortRepo = new TagCohortRepository(); + $cohort = $cohortRepo->newQuery()->where('cohort_id', $cohortId)->first(); + + if (!$cohort) { + return ApiResponseHelper::error('人群快照不存在', 404, 404); + } + + $cohort->delete(); + + LoggerHelper::logBusiness('delete_tag_cohort', [ + 'cohort_id' => $cohortId, + ]); + + return ApiResponseHelper::success(null, '人群快照删除成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 导出人群快照 + * + * POST /api/tag-cohorts/{cohort_id}/export + */ + public function export(Request $request, string $cohortId): Response + { + try { + LoggerHelper::logRequest('POST', "/api/tag-cohorts/{$cohortId}/export"); + + $cohortRepo = new TagCohortRepository(); + $cohort = $cohortRepo->newQuery()->where('cohort_id', $cohortId)->first(); + + if (!$cohort) { + return ApiResponseHelper::error('人群快照不存在', 404, 404); + } + + $userIds = $cohort->user_ids ?? []; + $userProfileRepo = new UserProfileRepository(); + + // 获取用户信息 + $users = []; + foreach ($userIds as $userId) { + $user = $userProfileRepo->findByUserId($userId); + if ($user) { + $users[] = [ + 'user_id' => $user->user_id, + 'phone' => $user->phone ?? '', + 'name' => $user->name ?? '', + ]; + } + } + + // 生成 CSV 内容 + $csvContent = "用户ID,手机号,姓名\n"; + foreach ($users as $user) { + $csvContent .= sprintf( + "%s,%s,%s\n", + $user['user_id'], + $user['phone'], + $user['name'] + ); + } + + LoggerHelper::logBusiness('export_tag_cohort', [ + 'cohort_id' => $cohortId, + 'user_count' => count($users), + ]); + + // 返回 CSV 文件 + return response($csvContent) + ->header('Content-Type', 'text/csv; charset=utf-8') + ->header('Content-Disposition', "attachment; filename=\"cohort_{$cohortId}.csv\""); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } +} + diff --git a/app/controller/TagController.php b/app/controller/TagController.php new file mode 100644 index 0000000..630fafc --- /dev/null +++ b/app/controller/TagController.php @@ -0,0 +1,521 @@ +path(); + if (preg_match('#/api/users/([^/]+)/tags#', $path, $matches)) { + $userId = $matches[1]; + } else { + // 如果路径解析失败,尝试从查询参数获取 + $userId = $request->get('user_id'); + if (!$userId) { + throw new \InvalidArgumentException('缺少 user_id 参数'); + } + } + + LoggerHelper::logRequest('GET', $path, ['user_id' => $userId]); + + $userTagRepo = new UserTagRepository(); + $tagDefRepo = new TagDefinitionRepository(); + + // 查询用户的所有标签 + $userTags = $userTagRepo->newQuery() + ->where('user_id', $userId) + ->get(); + + // 关联标签定义信息 + $result = []; + foreach ($userTags as $userTag) { + $tagDef = $tagDefRepo->newQuery() + ->where('tag_id', $userTag->tag_id) + ->first(); + + $result[] = [ + 'tag_id' => $userTag->tag_id, + 'tag_code' => $tagDef ? $tagDef->tag_code : null, + 'tag_name' => $tagDef ? $tagDef->tag_name : null, + 'category' => $tagDef ? $tagDef->category : null, + 'tag_value' => $userTag->tag_value, + 'tag_value_type' => $userTag->tag_value_type, + 'confidence' => $userTag->confidence, + 'effective_time' => $userTag->effective_time, + 'expire_time' => $userTag->expire_time, + 'update_time' => $userTag->update_time, + ]; + } + + LoggerHelper::logBusiness('get_user_tags', [ + 'user_id' => $userId, + 'tag_count' => count($result), + ]); + + return ApiResponseHelper::success([ + 'user_id' => $userId, + 'tags' => $result, + 'count' => count($result), + ]); + } catch (\InvalidArgumentException $e) { + return ApiResponseHelper::error($e->getMessage(), 400); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 更新/计算用户标签 + * + * PUT /api/users/{user_id}/tags + */ + public function calculate(Request $request): Response + { + $startTime = microtime(true); + + try { + // 从请求路径中解析 user_id + $path = $request->path(); + if (preg_match('#/api/users/([^/]+)/tags#', $path, $matches)) { + $userId = $matches[1]; + } else { + // 如果路径解析失败,尝试从查询参数获取 + $userId = $request->get('user_id'); + if (!$userId) { + throw new \InvalidArgumentException('缺少 user_id 参数'); + } + } + + LoggerHelper::logRequest('PUT', $path, ['user_id' => $userId]); + + $tagService = new TagService( + new TagDefinitionRepository(), + new UserProfileRepository(), + new UserTagRepository(), + new TagHistoryRepository(), + new SimpleRuleEngine() + ); + + $tags = $tagService->calculateTags($userId); + + $duration = microtime(true) - $startTime; + LoggerHelper::logBusiness('calculate_tags', [ + 'user_id' => $userId, + 'updated_count' => count($tags), + ], 'info'); + LoggerHelper::logPerformance('tag_calculation', $duration, [ + 'user_id' => $userId, + 'tag_count' => count($tags), + ]); + + return ApiResponseHelper::success([ + 'user_id' => $userId, + 'updated_tags' => $tags, + 'count' => count($tags), + ]); + } catch (\InvalidArgumentException $e) { + return ApiResponseHelper::error($e->getMessage(), 400); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 删除用户的指定标签 + * + * DELETE /api/users/{user_id}/tags/{tag_id} + */ + public function destroy(Request $request): Response + { + try { + // 从请求路径中解析 user_id 和 tag_id + $path = $request->path(); + if (preg_match('#/api/users/([^/]+)/tags/([^/]+)#', $path, $matches)) { + $userId = $matches[1]; + $tagId = $matches[2]; + } else { + throw new \InvalidArgumentException('缺少 user_id 或 tag_id 参数'); + } + + LoggerHelper::logRequest('DELETE', $path, ['user_id' => $userId, 'tag_id' => $tagId]); + + $tagService = new TagService( + new TagDefinitionRepository(), + new UserProfileRepository(), + new UserTagRepository(), + new TagHistoryRepository(), + new SimpleRuleEngine() + ); + + $deleted = $tagService->deleteUserTag($userId, $tagId); + + if (!$deleted) { + return ApiResponseHelper::error('标签不存在', 404, 404); + } + + LoggerHelper::logBusiness('tag_deleted', [ + 'user_id' => $userId, + 'tag_id' => $tagId, + ]); + + return ApiResponseHelper::success(null, '标签删除成功'); + } catch (\InvalidArgumentException $e) { + return ApiResponseHelper::error($e->getMessage(), 400); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 根据标签筛选用户 + * + * POST /api/tags/filter + */ + public function filter(Request $request): Response + { + try { + LoggerHelper::logRequest('POST', '/api/tags/filter'); + + $rawBody = $request->rawBody(); + + if (empty($rawBody)) { + return ApiResponseHelper::error('请求体为空,请确保 Content-Type 为 application/json 并发送有效的 JSON 数据', 400); + } + + $body = json_decode($rawBody, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return ApiResponseHelper::error('JSON 格式错误: ' . json_last_error_msg(), 400); + } + + // 验证必填字段 + if (empty($body['tag_conditions']) || !is_array($body['tag_conditions'])) { + throw new \InvalidArgumentException('缺少必填字段:tag_conditions(必须为数组)'); + } + + // 验证条件格式 + foreach ($body['tag_conditions'] as $condition) { + if (!isset($condition['tag_code']) || !isset($condition['operator']) || !isset($condition['value'])) { + throw new \InvalidArgumentException('每个条件必须包含 tag_code、operator 和 value 字段'); + } + } + + $tagService = new TagService( + new TagDefinitionRepository(), + new UserProfileRepository(), + new UserTagRepository(), + new TagHistoryRepository(), + new SimpleRuleEngine() + ); + + $conditions = $body['tag_conditions']; + $logic = $body['logic'] ?? 'AND'; + $page = (int)($body['page'] ?? 1); + $pageSize = (int)($body['page_size'] ?? 20); + $includeUserInfo = (bool)($body['include_user_info'] ?? false); + + if ($page < 1) { + $page = 1; + } + if ($pageSize < 1 || $pageSize > 100) { + $pageSize = 20; + } + + $result = $tagService->filterUsersByTags( + $conditions, + $logic, + $page, + $pageSize, + $includeUserInfo + ); + + // 对返回的用户信息进行脱敏处理 + if ($includeUserInfo && isset($result['users']) && is_array($result['users'])) { + foreach ($result['users'] as &$user) { + $user = DataMaskingHelper::maskArray($user, ['phone', 'email']); + } + unset($user); + } + + LoggerHelper::logBusiness('filter_users_by_tags', [ + 'conditions_count' => count($conditions), + 'logic' => $logic, + 'result_count' => $result['total'] ?? 0, + ]); + + return ApiResponseHelper::success($result); + } catch (\InvalidArgumentException $e) { + return ApiResponseHelper::error($e->getMessage(), 400); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 批量初始化标签定义 + * + * POST /api/tag-definitions/batch + */ + public function init(Request $request): Response + { + try { + LoggerHelper::logRequest('POST', '/api/tag-definitions/batch'); + + $initService = new \app\service\TagInitService( + new TagDefinitionRepository() + ); + + $initService->initBasicTags(); + + LoggerHelper::logBusiness('init_tags', []); + + return ApiResponseHelper::success([ + 'message' => '标签初始化完成', + ]); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 获取标签统计信息 + * + * GET /api/tags/statistics + */ + public function statistics(Request $request): Response + { + try { + LoggerHelper::logRequest('GET', '/api/tags/statistics'); + + $tagId = $request->get('tag_id'); + $startDate = $request->get('start_date'); + $endDate = $request->get('end_date'); + + $userTagRepo = new UserTagRepository(); + $tagDefRepo = new TagDefinitionRepository(); + + $result = [ + 'value_distribution' => [], + 'trend_data' => [], + 'coverage_stats' => [], + ]; + + // 如果指定了 tag_id,统计该标签的值分布 + if ($tagId) { + $tagDef = $tagDefRepo->newQuery()->where('tag_id', $tagId)->first(); + if ($tagDef) { + // 统计标签值分布 + $userTags = $userTagRepo->newQuery() + ->where('tag_id', $tagId) + ->get(['tag_value']); + + $valueCounts = []; + foreach ($userTags as $userTag) { + $value = (string)$userTag->tag_value; + if (!isset($valueCounts[$value])) { + $valueCounts[$value] = 0; + } + $valueCounts[$value]++; + } + + // 按数量排序 + arsort($valueCounts); + $valueCounts = array_slice($valueCounts, 0, 20, true); + + foreach ($valueCounts as $value => $count) { + $result['value_distribution'][] = [ + 'value' => $value, + 'count' => $count + ]; + } + + // 统计标签覆盖度 + $totalUsers = $userTagRepo->newQuery() + ->distinct('user_id') + ->count(); + + $taggedUsers = $userTagRepo->newQuery() + ->where('tag_id', $tagId) + ->distinct('user_id') + ->count(); + + $result['coverage_stats'][] = [ + 'tag_id' => $tagId, + 'tag_name' => $tagDef->tag_name ?? '', + 'total_users' => $totalUsers, + 'tagged_users' => $taggedUsers, + 'coverage_rate' => $totalUsers > 0 ? round($taggedUsers / $totalUsers * 100, 2) : 0 + ]; + } + } else { + // 统计所有标签的覆盖度 + $tagDefs = $tagDefRepo->newQuery()->where('status', 1)->get(); + $totalUsers = $userTagRepo->newQuery()->distinct('user_id')->count(); + + foreach ($tagDefs as $tagDef) { + $taggedUsers = $userTagRepo->newQuery() + ->where('tag_id', $tagDef->tag_id) + ->distinct('user_id') + ->count(); + + $result['coverage_stats'][] = [ + 'tag_id' => $tagDef->tag_id, + 'tag_name' => $tagDef->tag_name ?? '', + 'total_users' => $totalUsers, + 'tagged_users' => $taggedUsers, + 'coverage_rate' => $totalUsers > 0 ? round($taggedUsers / $totalUsers * 100, 2) : 0 + ]; + } + } + + // 趋势数据(如果有时间范围) + if ($startDate && $endDate) { + $historyRepo = new TagHistoryRepository(); + $start = new \DateTime($startDate); + $end = new \DateTime($endDate); + + // 按日期统计标签变更次数 + $trendData = []; + $current = clone $start; + while ($current <= $end) { + $dateStr = $current->format('Y-m-d'); + $nextDay = clone $current; + $nextDay->modify('+1 day'); + + $count = $historyRepo->newQuery() + ->where('change_time', '>=', $current) + ->where('change_time', '<', $nextDay) + ->count(); + + $trendData[] = [ + 'date' => $dateStr, + 'count' => $count + ]; + + $current->modify('+1 day'); + } + + $result['trend_data'] = $trendData; + } + + LoggerHelper::logBusiness('get_tag_statistics', [ + 'tag_id' => $tagId, + 'start_date' => $startDate, + 'end_date' => $endDate, + ]); + + return ApiResponseHelper::success($result); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 获取标签历史记录 + * + * GET /api/tags/history + */ + public function history(Request $request): Response + { + try { + LoggerHelper::logRequest('GET', '/api/tags/history'); + + $userId = $request->get('user_id'); + $tagId = $request->get('tag_id'); + $startDate = $request->get('start_date'); + $endDate = $request->get('end_date'); + $page = (int)($request->get('page') ?? 1); + $pageSize = (int)($request->get('page_size') ?? 20); + + if ($page < 1) { + $page = 1; + } + if ($pageSize < 1 || $pageSize > 100) { + $pageSize = 20; + } + + $historyRepo = new TagHistoryRepository(); + $tagDefRepo = new TagDefinitionRepository(); + + $query = $historyRepo->newQuery(); + + if ($userId) { + $query->where('user_id', $userId); + } + + if ($tagId) { + $query->where('tag_id', $tagId); + } + + if ($startDate) { + $query->where('change_time', '>=', new \DateTime($startDate)); + } + + if ($endDate) { + $endDateTime = new \DateTime($endDate); + $endDateTime->modify('+1 day'); + $query->where('change_time', '<', $endDateTime); + } + + $total = $query->count(); + + $histories = $query->orderBy('change_time', 'desc') + ->skip(($page - 1) * $pageSize) + ->take($pageSize) + ->get(); + + $items = []; + foreach ($histories as $history) { + $tagDef = $tagDefRepo->newQuery()->where('tag_id', $history->tag_id)->first(); + + $items[] = [ + 'user_id' => $history->user_id, + 'tag_id' => $history->tag_id, + 'tag_name' => $tagDef ? $tagDef->tag_name : null, + 'old_value' => $history->old_value, + 'new_value' => $history->new_value, + 'change_reason' => $history->change_reason, + 'change_time' => $history->change_time ? $history->change_time->format('Y-m-d H:i:s') : null, + 'operator' => $history->operator, + ]; + } + + LoggerHelper::logBusiness('get_tag_history', [ + 'user_id' => $userId, + 'tag_id' => $tagId, + 'total' => $total, + ]); + + return ApiResponseHelper::success([ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'page_size' => $pageSize, + ]); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + +} + diff --git a/app/controller/TagDefinitionController.php b/app/controller/TagDefinitionController.php new file mode 100644 index 0000000..def47f8 --- /dev/null +++ b/app/controller/TagDefinitionController.php @@ -0,0 +1,155 @@ +query(); + + // 筛选条件 + if ($request->get('category')) { + $query->where('category', $request->get('category')); + } + if ($request->get('status')) { + $query->where('status', $request->get('status')); + } + if ($request->get('name')) { + $query->where('tag_name', 'like', '%' . $request->get('name') . '%'); + } + + $page = (int)($request->get('page', 1)); + $pageSize = (int)($request->get('page_size', 20)); + + $total = $query->count(); + $definitions = $query->orderBy('created_at', 'desc') + ->skip(($page - 1) * $pageSize) + ->take($pageSize) + ->get() + ->toArray(); + + return ApiResponseHelper::success([ + 'definitions' => $definitions, + 'total' => $total, + 'page' => $page, + 'page_size' => $pageSize, + 'total_pages' => ceil($total / $pageSize), + ], '查询成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 获取标签定义详情 + * + * GET /api/tag-definitions/{tag_id} + */ + public function detail(Request $request, string $tagId): Response + { + try { + $repo = new TagDefinitionRepository(); + $definition = $repo->find($tagId); + + if (!$definition) { + return ApiResponseHelper::error('标签定义不存在', 404); + } + + return ApiResponseHelper::success($definition->toArray(), '查询成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 创建标签定义 + * + * POST /api/tag-definitions + */ + public function create(Request $request): Response + { + try { + $data = $request->post(); + + $requiredFields = ['tag_code', 'tag_name', 'category']; + foreach ($requiredFields as $field) { + if (empty($data[$field])) { + return ApiResponseHelper::error("缺少必填字段: {$field}", 400); + } + } + + $repo = new TagDefinitionRepository(); + $definition = $repo->create($data); + + return ApiResponseHelper::success($definition->toArray(), '创建成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 更新标签定义 + * + * PUT /api/tag-definitions/{tag_id} + */ + public function update(Request $request, string $tagId): Response + { + try { + $data = $request->post(); + + $repo = new TagDefinitionRepository(); + $definition = $repo->find($tagId); + + if (!$definition) { + return ApiResponseHelper::error('标签定义不存在', 404); + } + + $definition->fill($data); + $definition->save(); + + return ApiResponseHelper::success($definition->toArray(), '更新成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 删除标签定义 + * + * DELETE /api/tag-definitions/{tag_id} + */ + public function delete(Request $request, string $tagId): Response + { + try { + $repo = new TagDefinitionRepository(); + $definition = $repo->find($tagId); + + if (!$definition) { + return ApiResponseHelper::error('标签定义不存在', 404); + } + + $definition->delete(); + + return ApiResponseHelper::success(null, '删除成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } +} + diff --git a/app/controller/TagTaskController.php b/app/controller/TagTaskController.php new file mode 100644 index 0000000..b8bb81c --- /dev/null +++ b/app/controller/TagTaskController.php @@ -0,0 +1,227 @@ +post(); + + $requiredFields = ['name', 'task_type']; + foreach ($requiredFields as $field) { + if (empty($data[$field])) { + return ApiResponseHelper::error("缺少必填字段: {$field}", 400); + } + } + + $service = $this->getService(); + $task = $service->createTask($data); + + return ApiResponseHelper::success($task, '任务创建成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 更新任务 + * + * PUT /api/tag-tasks/{task_id} + */ + public function update(Request $request, string $taskId): Response + { + try { + $data = $request->post(); + + $service = $this->getService(); + $result = $service->updateTask($taskId, $data); + + return ApiResponseHelper::success(null, '任务更新成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 删除任务 + * + * DELETE /api/tag-tasks/{task_id} + */ + public function delete(Request $request, string $taskId): Response + { + try { + $service = $this->getService(); + $result = $service->deleteTask($taskId); + + return ApiResponseHelper::success(null, '任务删除成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 启动任务 + * + * POST /api/tag-tasks/{task_id}/start + */ + public function start(Request $request, string $taskId): Response + { + try { + $service = $this->getService(); + $result = $service->startTask($taskId); + + return ApiResponseHelper::success(null, '任务启动成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 暂停任务 + * + * POST /api/tag-tasks/{task_id}/pause + */ + public function pause(Request $request, string $taskId): Response + { + try { + $service = $this->getService(); + $result = $service->pauseTask($taskId); + + return ApiResponseHelper::success(null, '任务暂停成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 停止任务 + * + * POST /api/tag-tasks/{task_id}/stop + */ + public function stop(Request $request, string $taskId): Response + { + try { + $service = $this->getService(); + $result = $service->stopTask($taskId); + + return ApiResponseHelper::success(null, '任务停止成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 获取任务列表 + * + * GET /api/tag-tasks + */ + public function list(Request $request): Response + { + try { + $filters = [ + 'status' => $request->get('status'), + 'task_type' => $request->get('task_type'), + 'name' => $request->get('name'), + ]; + + $page = (int)($request->get('page', 1)); + $pageSize = (int)($request->get('page_size', 20)); + + $service = $this->getService(); + $result = $service->getTaskList($filters, $page, $pageSize); + + return ApiResponseHelper::success($result, '查询成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 获取任务详情 + * + * GET /api/tag-tasks/{task_id} + */ + public function detail(Request $request, string $taskId): Response + { + try { + $service = $this->getService(); + $task = $service->getTask($taskId); + + if ($task === null) { + return ApiResponseHelper::error('任务不存在', 404); + } + + return ApiResponseHelper::success($task, '查询成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 获取任务执行记录 + * + * GET /api/tag-tasks/{task_id}/executions + */ + public function executions(Request $request, string $taskId): Response + { + try { + $page = (int)($request->get('page', 1)); + $pageSize = (int)($request->get('page_size', 20)); + + $service = $this->getService(); + $result = $service->getExecutions($taskId, $page, $pageSize); + + return ApiResponseHelper::success($result, '查询成功'); + } catch (\Throwable $e) { + return ApiResponseHelper::exception($e); + } + } + + /** + * 获取服务实例 + */ + private function getService(): TagTaskService + { + return new TagTaskService( + new TagTaskRepository(), + new TagTaskExecutionRepository(), + new UserProfileRepository(), + new TagService( + new TagDefinitionRepository(), + new UserProfileRepository(), + new UserTagRepository(), + new TagHistoryRepository(), + new SimpleRuleEngine() + ) + ); + } +} + diff --git a/app/process/DataSyncScheduler.php b/app/process/DataSyncScheduler.php new file mode 100644 index 0000000..5fe716e --- /dev/null +++ b/app/process/DataSyncScheduler.php @@ -0,0 +1,695 @@ +taskService = new DataCollectionTaskService( + new DataCollectionTaskRepository() + ); + + // 初始化数据源服务 + $this->dataSourceService = new DataSourceService( + new DataSourceRepository() + ); + + // 初始化标签任务服务(避免在多个方法中重复实例化) + $this->tagTaskService = new \app\service\TagTaskService( + new \app\repository\TagTaskRepository(), + new \app\repository\TagTaskExecutionRepository(), + new \app\repository\UserProfileRepository(), + new \app\service\TagService( + new \app\repository\TagDefinitionRepository(), + new \app\repository\UserProfileRepository(), + new \app\repository\UserTagRepository(), + new \app\repository\TagHistoryRepository(), + new \app\service\TagRuleEngine\SimpleRuleEngine() + ) + ); + + // 加载任务采集配置(配置文件中的任务) + $taskConfig = config('data_collection_tasks', []); + $this->tasks = $taskConfig['tasks'] ?? []; + $this->globalConfig = $taskConfig['global'] ?? []; + + // 从数据库加载数据源配置(替代config('data_sources')) + $this->loadDataSourcesConfig(); + + LoggerHelper::logBusiness('data_collection_scheduler_started', [ + 'worker_id' => $worker->id, + 'total_workers' => $worker->count, + 'config_task_count' => count($this->tasks), + 'data_source_count' => count($this->dataSourcesConfig), + ]); + + // 加载配置文件中的任务 + $this->loadConfigTasks($worker); + + // 加载数据库中的动态任务 + $this->loadDatabaseTasks($worker); + + // 每30秒刷新一次数据库任务列表(检查新任务、状态变更等) + Timer::add(30, function () use ($worker) { + $this->refreshDatabaseTasks($worker); + }); + } + + /** + * 从数据库加载数据源配置 + */ + private function loadDataSourcesConfig(): void + { + try { + $this->dataSourcesConfig = $this->dataSourceService->getAllEnabledDataSources(); + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'DataSyncScheduler', + 'action' => 'loadDataSourcesConfig', + ]); + // 如果加载失败,使用空数组,避免后续错误 + $this->dataSourcesConfig = []; + } + } + + /** + * 加载配置文件中的任务 + */ + private function loadConfigTasks(Worker $worker): void + { + // 为每个启用的任务设置定时任务 + foreach ($this->tasks as $taskId => $taskConfig) { + if (!($taskConfig['enabled'] ?? true)) { + continue; + } + + // 检查是否应该由当前 Worker 处理(分片分配) + if (!$this->shouldHandleTask($taskId, $taskConfig, $worker)) { + continue; + } + + // 获取调度配置 + $schedule = $taskConfig['schedule'] ?? []; + + // 如果调度被禁用,直接启动任务(持续运行) + if (!($schedule['enabled'] ?? true)) { + // 使用 Timer 延迟启动,避免阻塞 Worker 启动 + Timer::add(0, function () use ($taskId, $taskConfig, $worker) { + $this->executeTask($taskId, $taskConfig, $worker); + }, [], false); + continue; + } + + $cronExpression = $schedule['cron'] ?? '*/5 * * * *'; // 默认每5分钟 + + // 创建定时任务 + $this->scheduleTask($taskId, $taskConfig, $cronExpression, $worker); + } + } + + /** + * 加载数据库中的动态任务 + */ + private function loadDatabaseTasks(Worker $worker): void + { + try { + // 加载数据采集任务 + $dataCollectionTaskService = new \app\service\DataCollectionTaskService( + new \app\repository\DataCollectionTaskRepository() + ); + $runningCollectionTasks = $dataCollectionTaskService->getRunningTasks(); + + \Workerman\Worker::safeEcho("[DataSyncScheduler] 从数据库加载到 " . count($runningCollectionTasks) . " 个运行中的任务 (worker_id={$worker->id})\n"); + + foreach ($runningCollectionTasks as $task) { + $taskId = $task['task_id']; + $taskName = $task['name'] ?? $taskId; + + if (!$this->shouldHandleDatabaseTask($taskId, $worker)) { + \Workerman\Worker::safeEcho("[DataSyncScheduler] 跳过任务 [{$taskName}],由其他 Worker 处理 (worker_id={$worker->id})\n"); + continue; + } + + \Workerman\Worker::safeEcho("[DataSyncScheduler] ✓ 准备启动任务: [{$taskName}] task_id={$taskId} (worker_id={$worker->id})\n"); + + $taskConfig = $this->convertDatabaseTaskToConfig($task); + + // 使用统一的任务启动方法 + $this->startTask($taskId, $taskConfig, $worker); + } + + // 加载标签任务(使用已初始化的服务) + $runningTagTasks = $this->tagTaskService->getTaskList(['status' => 'running'], 1, 1000); + + foreach ($runningTagTasks['tasks'] as $task) { + $taskId = $task['task_id']; + + if (!$this->shouldHandleDatabaseTask($taskId, $worker)) { + continue; + } + + \Workerman\Worker::safeEcho("[DataSyncScheduler] ✓ 准备启动标签任务: task_id={$taskId} (worker_id={$worker->id})\n"); + + $taskConfig = $this->convertDatabaseTaskToConfig($task); + + // 标签任务通常是批量执行,根据调度配置执行 + $schedule = $taskConfig['schedule'] ?? []; + if (!($schedule['enabled'] ?? true)) { + // 立即执行一次 + \Workerman\Worker::safeEcho("[DataSyncScheduler] → 立即执行标签任务\n"); + Timer::add(0, function () use ($taskId, $taskConfig, $worker) { + $this->executeTask($taskId, $taskConfig, $worker); + }, [], false); + } else { + $cronExpression = $schedule['cron'] ?? '0 2 * * *'; // 默认每天凌晨2点 + \Workerman\Worker::safeEcho("[DataSyncScheduler] → 定时执行标签任务,Cron: {$cronExpression}\n"); + $this->scheduleTask($taskId, $taskConfig, $cronExpression, $worker); + } + } + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'DataSyncScheduler', + 'action' => 'loadDatabaseTasks', + ]); + } + } + + /** + * 刷新数据库任务列表 + */ + private function refreshDatabaseTasks(Worker $worker): void + { + try { + // 刷新数据采集任务 + $dataCollectionTaskService = new \app\service\DataCollectionTaskService( + new \app\repository\DataCollectionTaskRepository() + ); + $runningCollectionTasks = $dataCollectionTaskService->getRunningTasks(); + $collectionTaskIds = array_column($runningCollectionTasks, 'task_id'); + + foreach ($runningCollectionTasks as $task) { + $taskId = $task['task_id']; + + if (isset($this->runningTasks[$taskId])) { + \Workerman\Worker::safeEcho("[DataSyncScheduler] 任务 {$taskId} 已在运行中,跳过\n"); + continue; + } + + \Workerman\Worker::safeEcho("[DataSyncScheduler] 检测到新的运行中任务: {$taskId}\n"); + + $taskConfig = $this->convertDatabaseTaskToConfig($task); + + // 使用统一的任务启动方法 + $this->startTask($taskId, $taskConfig, $worker); + } + + // 刷新标签任务(使用已初始化的服务) + $runningTagTasks = $this->tagTaskService->getTaskList(['status' => 'running'], 1, 1000); + $tagTaskIds = array_column($runningTagTasks['tasks'], 'task_id'); + + foreach ($runningTagTasks['tasks'] as $task) { + $taskId = $task['task_id']; + + if (isset($this->runningTasks[$taskId])) { + continue; + } + + if (RedisHelper::exists("tag_task:{$taskId}:start")) { + RedisHelper::del("tag_task:{$taskId}:start"); + + $taskConfig = $this->convertDatabaseTaskToConfig($task); + + Timer::add(0, function () use ($taskId, $taskConfig, $worker) { + $this->executeTask($taskId, $taskConfig, $worker); + }, [], false); + $this->runningTasks[$taskId] = true; + } + } + + // 检查是否有任务需要停止或暂停 + $allTaskIds = array_merge($collectionTaskIds, $tagTaskIds); + foreach (array_keys($this->runningTasks) as $taskId) { + if (!in_array($taskId, $allTaskIds)) { + // 任务不在运行列表中了,移除 + unset($this->runningTasks[$taskId]); + } elseif (RedisHelper::exists("data_collection_task:{$taskId}:stop") || + RedisHelper::exists("tag_task:{$taskId}:stop")) { + // 检查停止标志 + RedisHelper::del("data_collection_task:{$taskId}:stop"); + RedisHelper::del("tag_task:{$taskId}:stop"); + unset($this->runningTasks[$taskId]); + \Workerman\Worker::safeEcho("[DataSyncScheduler] 检测到停止信号,任务 {$taskId} 已停止\n"); + } elseif (RedisHelper::exists("data_collection_task:{$taskId}:pause")) { + // 检查暂停标志 + RedisHelper::del("data_collection_task:{$taskId}:pause"); + unset($this->runningTasks[$taskId]); + \Workerman\Worker::safeEcho("[DataSyncScheduler] 检测到暂停信号,任务 {$taskId} 已暂停(正在执行的任务会在下次检查时停止)\n"); + } + } + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'DataSyncScheduler', + 'action' => 'refreshDatabaseTasks', + ]); + } + } + + /** + * 判断是否应该处理数据库任务 + */ + private function shouldHandleDatabaseTask(string $taskId, Worker $worker): bool + { + // 简单策略:按 Worker ID 取模分配 + // 可以根据需要调整分配策略 + return ($worker->id % $worker->count) === (hexdec(substr($taskId, 0, 8)) % $worker->count); + } + + /** + * 将数据库任务转换为配置格式 + */ + private function convertDatabaseTaskToConfig(array $task): array + { + // 判断任务类型:数据采集任务还是标签任务 + // 如果任务有 data_source_id,说明是数据采集任务 + // 如果任务有 target_tag_ids,说明是标签任务 + if (isset($task['data_source_id']) && !empty($task['data_source_id'])) { + // 数据采集任务 + // 根据 target_type 选择不同的 Handler + $targetType = $task['target_type'] ?? 'generic'; + $handlerClass = \app\service\DataCollection\Handler\GenericCollectionHandler::class; + + if ($targetType === 'consumption_record') { + $handlerClass = \app\service\DataCollection\Handler\ConsumptionCollectionHandler::class; + } + + $config = [ + 'task_id' => $task['task_id'], + 'name' => $task['name'] ?? '', + 'data_source_id' => $task['data_source_id'], + 'data_source' => $task['data_source_id'], + 'database' => $task['database'] ?? '', + 'collection' => $task['collection'] ?? null, + 'collections' => $task['collections'] ?? null, + 'target_type' => $targetType, + 'target_data_source_id' => $task['target_data_source_id'] ?? null, + 'target_database' => $task['target_database'] ?? null, + 'target_collection' => $task['target_collection'] ?? null, + 'mode' => $task['mode'] ?? 'batch', + 'field_mappings' => $task['field_mappings'] ?? [], + 'collection_field_mappings' => $task['collection_field_mappings'] ?? [], + 'lookups' => $task['lookups'] ?? [], + 'collection_lookups' => $task['collection_lookups'] ?? [], + 'filter_conditions' => $task['filter_conditions'] ?? [], + 'schedule' => $task['schedule'] ?? [ + 'enabled' => false, + 'cron' => null, + ], + 'handler_class' => $handlerClass, + 'batch_size' => $task['batch_size'] ?? 1000, + ]; + + // 对于consumption_record类型的任务,添加source_type字段(如果存在) + if ($targetType === 'consumption_record' && isset($task['source_type'])) { + $config['source_type'] = $task['source_type']; + } + + return $config; + } elseif (isset($task['target_tag_ids']) || isset($task['task_type'])) { + // 标签任务 + return [ + 'task_id' => $task['task_id'], + 'name' => $task['name'] ?? '', + 'task_type' => $task['task_type'] ?? 'full', + 'target_tag_ids' => $task['target_tag_ids'] ?? [], + 'user_scope' => $task['user_scope'] ?? ['type' => 'all'], + 'schedule' => $task['schedule'] ?? [ + 'enabled' => false, + 'cron' => null, + ], + 'config' => $task['config'] ?? [ + 'concurrency' => 10, + 'batch_size' => 100, + 'error_handling' => 'skip', + ], + 'handler_class' => \app\service\DataCollection\Handler\TagTaskHandler::class, + ]; + } else { + throw new \InvalidArgumentException("无法识别任务类型: {$task['task_id']}"); + } + } + + /** + * 判断当前 Worker 是否应该处理该任务(分片分配) + * + * @param string $taskId 任务ID + * @param array $taskConfig 任务配置 + * @param Worker $worker Worker 实例 + * @return bool 是否应该处理 + */ + private function shouldHandleTask(string $taskId, array $taskConfig, Worker $worker): bool + { + $sharding = $taskConfig['sharding'] ?? []; + $strategy = $sharding['strategy'] ?? 'none'; + + // 如果不需要分片,所有 Worker 都处理(但通过分布式锁保证只有一个执行) + if ($strategy === 'none') { + return true; + } + + // by_database 策略:所有 Worker 都处理,但每个 Worker 处理不同的数据库(在 Handler 中分配) + if ($strategy === 'by_database') { + return true; // 所有 Worker 都处理,数据库分配在 Handler 中进行 + } + + // 其他分片策略:按 Worker ID 取模分配 + $shardCount = $sharding['shard_count'] ?? 1; + if ($shardCount <= 1) { + // 如果 shard_count <= 1,所有 Worker 都处理 + return true; + } + + $assignedShardId = $worker->id % $shardCount; + // 对于分片策略,只处理分配给当前 Worker 的分片 + return $assignedShardId === ($worker->id % $shardCount); + } + + /** + * 为任务设置定时任务 + * + * @param string $taskId 任务ID + * @param array $taskConfig 任务配置 + * @param string $cronExpression Cron 表达式 + * @param Worker $worker Worker 实例 + * @return void + */ + private function scheduleTask(string $taskId, array $taskConfig, string $cronExpression, Worker $worker): void + { + try { + $cron = CronExpression::factory($cronExpression); + + // 计算下次执行时间 + $nextRunTime = $cron->getNextRunDate()->getTimestamp(); + $now = time(); + $delay = max(0, $nextRunTime - $now); + + LoggerHelper::logBusiness('data_collection_task_scheduled', [ + 'task_id' => $taskId, + 'task_name' => $taskConfig['name'] ?? $taskId, + 'cron' => $cronExpression, + 'next_run_time' => date('Y-m-d H:i:s', $nextRunTime), + 'delay' => $delay, + ]); + + // 设置定时器(延迟执行第一次,然后每60秒检查一次) + Timer::add(60, function () use ($taskId, $taskConfig, $cron, $worker) { + $now = time(); + $nextRunTime = $cron->getNextRunDate()->getTimestamp(); + + // 如果到了执行时间(允许1秒误差) + if ($nextRunTime <= $now + 1) { + $this->executeTask($taskId, $taskConfig, $worker); + } + }, [], false, $delay); + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'DataSyncScheduler', + 'action' => 'scheduleTask', + 'task_id' => $taskId, + 'cron' => $cronExpression, + ]); + } + } + + /** + * 执行采集任务 + * + * @param string $taskId 任务ID + * @param array $taskConfig 任务配置 + * @param Worker $worker Worker 实例 + * @return void + */ + private function executeTask(string $taskId, array $taskConfig, Worker $worker): void + { + // 在执行前,检查任务状态,如果已经是 completed,就不应该再执行 + try { + $task = $this->taskService->getTask($taskId); + if ($task && isset($task['status'])) { + if ($task['status'] === 'completed') { + \Workerman\Worker::safeEcho("[DataSyncScheduler] ⚠️ 任务已完成,跳过执行: task_id={$taskId}\n"); + LoggerHelper::logBusiness('data_collection_skipped_completed', [ + 'task_id' => $taskId, + 'worker_id' => $worker->id, + ]); + return; + } + if (in_array($task['status'], ['stopped', 'paused', 'error'])) { + \Workerman\Worker::safeEcho("[DataSyncScheduler] ⚠️ 任务状态为 {$task['status']},跳过执行: task_id={$taskId}\n"); + LoggerHelper::logBusiness('data_collection_skipped_status', [ + 'task_id' => $taskId, + 'status' => $task['status'], + 'worker_id' => $worker->id, + ]); + return; + } + } + } catch (\Throwable $e) { + // 如果查询任务状态失败,记录日志但继续执行(避免因为查询失败而阻塞任务) + LoggerHelper::logError($e, [ + 'component' => 'DataSyncScheduler', + 'action' => 'executeTask', + 'task_id' => $taskId, + 'message' => '检查任务状态失败,继续执行任务', + ]); + } + + $sharding = $taskConfig['sharding'] ?? []; + $strategy = $sharding['strategy'] ?? 'none'; + + // 对于 by_database 策略,不使用全局锁,让所有 Worker 并行执行 + // 每个 Worker 会在 Handler 中分配不同的数据库 + $useLock = ($strategy !== 'by_database'); + + if ($useLock) { + $lockKey = "data_collection:{$taskId}"; + $lockConfig = $this->globalConfig['distributed_lock'] ?? []; + $ttl = $lockConfig['ttl'] ?? 300; + $retryTimes = $lockConfig['retry_times'] ?? 3; + $retryDelay = $lockConfig['retry_delay'] ?? 1000; + + // 尝试获取分布式锁 + if (!RedisHelper::acquireLock($lockKey, $ttl, $retryTimes, $retryDelay)) { + LoggerHelper::logBusiness('data_collection_skipped_locked', [ + 'task_id' => $taskId, + 'worker_id' => $worker->id, + ]); + return; + } + } + + try { + LoggerHelper::logBusiness('data_collection_started', [ + 'task_id' => $taskId, + 'task_name' => $taskConfig['name'] ?? $taskId, + 'worker_id' => $worker->id, + ]); + // 控制台提示,标记具体哪个采集/同步任务被当前 Worker 拉起,方便排查任务是否真正执行 + \Workerman\Worker::safeEcho("[DataSyncScheduler] 【步骤1-任务启动】执行采集任务:task_id={$taskId}, task_name=" . ($taskConfig['name'] ?? $taskId) . ", worker_id={$worker->id}\n"); + + // 判断任务类型:数据采集任务需要数据源适配器,标签任务不需要 + $handlerClass = $taskConfig['handler_class'] ?? ''; + $isTagTask = strpos($handlerClass, 'TagTaskHandler') !== false; + + $adapter = null; + if (!$isTagTask) { + // 数据采集任务:需要数据源适配器 + // 支持两种配置方式: + // 1. 单数据源:data_source(用于普通采集任务) + // 2. 多数据源:source_data_source 和 target_data_source(用于数据库同步任务) + // 3. 动态任务:data_source_id(从数据库读取的任务) + $dataSourceId = $taskConfig['data_source'] ?? $taskConfig['data_source_id'] ?? ''; + $sourceDataSourceId = $taskConfig['source_data_source'] ?? ''; + + // 如果配置了 source_data_source,说明是多数据源任务(如数据库同步) + // 这种情况下,只需要创建源数据源适配器,目标数据源由 Handler 内部处理 + if (!empty($sourceDataSourceId)) { + $dataSourceId = $sourceDataSourceId; + } + + // 从缓存或数据库获取数据源配置 + \Workerman\Worker::safeEcho("[DataSyncScheduler] 【步骤2-查询数据源配置】开始查询数据源配置: data_source_id={$dataSourceId}\n"); + $dataSourceConfig = $this->dataSourcesConfig[$dataSourceId] ?? null; + + // 如果缓存中没有,尝试从数据库加载 + if (empty($dataSourceConfig)) { + \Workerman\Worker::safeEcho("[DataSyncScheduler] 【步骤2-查询数据源配置】缓存中没有,从数据库加载: data_source_id={$dataSourceId}\n"); + $dataSourceConfig = $this->dataSourceService->getDataSourceConfigById($dataSourceId); + if ($dataSourceConfig) { + // 更新缓存(使用原始 dataSourceId 作为 key) + $this->dataSourcesConfig[$dataSourceId] = $dataSourceConfig; + \Workerman\Worker::safeEcho("[DataSyncScheduler] 【步骤2-查询数据源配置】✓ 数据源配置加载成功: host={$dataSourceConfig['host']}, port={$dataSourceConfig['port']}, database={$dataSourceConfig['database']}\n"); + } else { + \Workerman\Worker::safeEcho("[DataSyncScheduler] 【步骤2-查询数据源配置】✗ 数据源配置不存在: data_source_id={$dataSourceId}\n"); + \Workerman\Worker::safeEcho("[DataSyncScheduler] 【步骤2-查询数据源配置】提示:请检查 data_sources 表中是否存在该数据源,或检查 name 字段是否匹配\n"); + } + } else { + \Workerman\Worker::safeEcho("[DataSyncScheduler] 【步骤2-查询数据源配置】✓ 从缓存获取数据源配置: host={$dataSourceConfig['host']}, port={$dataSourceConfig['port']}\n"); + } + + if (empty($dataSourceConfig)) { + throw new \InvalidArgumentException("数据源配置不存在: {$dataSourceId}"); + } + + // 创建数据源适配器 + \Workerman\Worker::safeEcho("[DataSyncScheduler] 【步骤3-创建数据源适配器】开始创建适配器: type={$dataSourceConfig['type']}\n"); + $adapter = DataSourceAdapterFactory::create( + $dataSourceConfig['type'], + $dataSourceConfig + ); + + // 确保适配器已连接 + if (!$adapter->isConnected()) { + \Workerman\Worker::safeEcho("[DataSyncScheduler] 【步骤3-创建数据源适配器】适配器未连接,尝试连接...\n"); + if (!$adapter->connect($dataSourceConfig)) { + \Workerman\Worker::safeEcho("[DataSyncScheduler] 【步骤3-创建数据源适配器】✗ 连接失败: data_source_id={$dataSourceId}\n"); + throw new \RuntimeException("无法连接到数据源: {$dataSourceId}"); + } + } + \Workerman\Worker::safeEcho("[DataSyncScheduler] 【步骤3-创建数据源适配器】✓ 数据源适配器创建并连接成功\n"); + } + + // 创建业务处理类(处理逻辑在业务代码中) + $handlerClass = $taskConfig['handler_class'] ?? ''; + \Workerman\Worker::safeEcho("[DataSyncScheduler] 【步骤4-创建Handler】开始创建Handler: handler_class={$handlerClass}\n"); + if (empty($handlerClass) || !class_exists($handlerClass)) { + \Workerman\Worker::safeEcho("[DataSyncScheduler] 【步骤4-创建Handler】✗ Handler类不存在: {$handlerClass}\n"); + throw new \InvalidArgumentException("处理类不存在或未配置: {$handlerClass}"); + } + + // 实例化处理类 + $handler = new $handlerClass(); + + // 检查处理类是否实现了采集接口 + if (!method_exists($handler, 'collect')) { + \Workerman\Worker::safeEcho("[DataSyncScheduler] 【步骤4-创建Handler】✗ Handler未实现collect方法: {$handlerClass}\n"); + throw new \InvalidArgumentException("处理类必须实现 collect 方法: {$handlerClass}"); + } + + // 添加任务ID到配置中 + $taskConfig['task_id'] = $taskId; + + // 添加 Worker 信息到配置中(用于多进程数据库分配) + $taskConfig['worker_id'] = $worker->id; + $taskConfig['worker_count'] = $worker->count; + + \Workerman\Worker::safeEcho("[DataSyncScheduler] 【步骤4-创建Handler】✓ Handler创建成功,开始调用collect方法\n"); + // 调用业务处理类的采集方法 + try { + $handler->collect($adapter, $taskConfig); + \Workerman\Worker::safeEcho("[DataSyncScheduler] 【步骤4-创建Handler】✓ Handler.collect()方法执行完成\n"); + } catch (\Throwable $collectException) { + \Workerman\Worker::safeEcho("[DataSyncScheduler] 【步骤4-创建Handler】✗ Handler.collect()方法执行失败: " . $collectException->getMessage() . "\n"); + \Workerman\Worker::safeEcho("[DataSyncScheduler] 【步骤4-创建Handler】异常堆栈: " . $collectException->getTraceAsString() . "\n"); + throw $collectException; // 重新抛出异常,让外层catch处理 + } + + LoggerHelper::logBusiness('data_collection_completed', [ + 'task_id' => $taskId, + 'task_name' => $taskConfig['name'] ?? $taskId, + ]); + } catch (\Throwable $e) { + // 在控制台输出异常信息,方便排查 + \Workerman\Worker::safeEcho("[DataSyncScheduler] 【异常捕获】任务执行失败: task_id={$taskId}, error=" . $e->getMessage() . "\n"); + \Workerman\Worker::safeEcho("[DataSyncScheduler] 【异常捕获】异常文件: " . $e->getFile() . ":" . $e->getLine() . "\n"); + \Workerman\Worker::safeEcho("[DataSyncScheduler] 【异常捕获】异常堆栈: " . $e->getTraceAsString() . "\n"); + + LoggerHelper::logError($e, [ + 'component' => 'DataSyncScheduler', + 'action' => 'executeTask', + 'task_id' => $taskId, + 'worker_id' => $worker->id, + ]); + } finally { + // 释放锁(仅在使用锁的情况下) + if (isset($useLock) && $useLock && isset($lockKey)) { + RedisHelper::releaseLock($lockKey); + } + } + } + + /** + * 统一的任务启动方法 + * + * @param string $taskId 任务ID + * @param array $taskConfig 任务配置 + * @param Worker $worker Worker 实例 + * @return void + */ + private function startTask(string $taskId, array $taskConfig, Worker $worker): void + { + $taskName = $taskConfig['name'] ?? $taskId; + + if ($taskConfig['mode'] === 'realtime') { + // 实时模式:立即启动,持续运行 + \Workerman\Worker::safeEcho("[DataSyncScheduler] → 实时模式任务: [{$taskName}]\n"); + Timer::add(0, function () use ($taskId, $taskConfig, $worker) { + $this->executeTask($taskId, $taskConfig, $worker); + }, [], false); + $this->runningTasks[$taskId] = true; + } else { + // 批量模式:根据调度配置执行 + \Workerman\Worker::safeEcho("[DataSyncScheduler] → 批量模式任务: [{$taskName}]\n"); + $schedule = $taskConfig['schedule'] ?? []; + if (!($schedule['enabled'] ?? true)) { + // 调度被禁用,立即执行一次 + \Workerman\Worker::safeEcho("[DataSyncScheduler] → 立即执行(调度已禁用)\n"); + Timer::add(0, function () use ($taskId, $taskConfig, $worker) { + $this->executeTask($taskId, $taskConfig, $worker); + }, [], false); + } else { + // 使用 Cron 表达式定时执行 + $cronExpression = $schedule['cron'] ?? '*/5 * * * *'; + \Workerman\Worker::safeEcho("[DataSyncScheduler] → 定时执行,Cron: {$cronExpression}\n"); + $this->scheduleTask($taskId, $taskConfig, $cronExpression, $worker); + } + } + } + + public function onWorkerStop(Worker $worker): void + { + LoggerHelper::logBusiness('data_collection_scheduler_stopped', [ + 'worker_id' => $worker->id, + ]); + } +} diff --git a/app/process/DataSyncWorker.php b/app/process/DataSyncWorker.php new file mode 100644 index 0000000..1a5276e --- /dev/null +++ b/app/process/DataSyncWorker.php @@ -0,0 +1,212 @@ +config = config('queue.connections.rabbitmq', []); + + // 初始化 DataSyncService + $this->dataSyncService = new DataSyncService( + new ConsumptionRecordRepository(), + new UserProfileRepository() + ); + + try { + // 建立 RabbitMQ 连接 + $this->connection = new AMQPStreamConnection( + $this->config['host'], + $this->config['port'], + $this->config['user'], + $this->config['password'], + $this->config['vhost'], + false, // insist + 'AMQPLAIN', // login_method + null, // login_response + 'en_US', // locale + $this->config['timeout'] ?? 10.0, // connection_timeout + $this->config['timeout'] ?? 10.0, // read_write_timeout + null, // context + false, // keepalive + $this->config['heartbeat'] ?? 0 // heartbeat + ); + + $this->channel = $this->connection->channel(); + + // 声明队列(确保队列存在) + $queueConfig = $this->config['queues']['data_sync']; + $this->channel->queue_declare( + $queueConfig['name'], + false, // passive + $queueConfig['durable'], + false, // exclusive + $queueConfig['auto_delete'], + false, // nowait + $queueConfig['arguments'] ?? [] + ); + + // 设置 QoS(批量处理) + $consumerConfig = config('queue.consumer.data_sync', []); + $this->channel->basic_qos( + null, // prefetch_size + $consumerConfig['prefetch_count'] ?? 10, // prefetch_count(每次处理10条消息) + false // global + ); + + // 注册消费者回调 + $this->channel->basic_consume( + $queueConfig['name'], + '', // consumer_tag + false, // no_local + false, // no_ack - 设为 false,手动确认 + false, // exclusive + false, // nowait + [$this, 'processMessage'] + ); + + LoggerHelper::logBusiness('data_sync_worker_started', [ + 'worker_id' => $worker->id, + ]); + + // 循环消费消息 + while ($this->channel->is_consuming()) { + $this->channel->wait(); + } + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'DataSyncWorker', + 'action' => 'onWorkerStart', + 'worker_id' => $worker->id, + ]); + $this->closeConnection(); + } + } + + /** + * 处理 RabbitMQ 消息 + * + * @param AMQPMessage $msg 消息对象 + * @return void + */ + public function processMessage(AMQPMessage $msg): void + { + $payload = json_decode($msg->getBody(), true); + $sourceId = $payload['source_id'] ?? 'unknown'; + $dataCount = count($payload['data'] ?? []); + + if (empty($payload['data'])) { + LoggerHelper::logBusiness('data_sync_message_empty', [ + 'source_id' => $sourceId, + ]); + $msg->ack(); // 确认消息,不再重试 + return; + } + + LoggerHelper::logBusiness('processing_data_sync_message', [ + 'source_id' => $sourceId, + 'data_count' => $dataCount, + 'worker_id' => Worker::getCurrentWorker()->id, + ]); + + try { + // 调用数据同步服务 + $result = $this->dataSyncService->syncData($payload); + + if ($result['success']) { + $msg->ack(); // 成功处理,发送 ACK + LoggerHelper::logBusiness('data_sync_message_processed', [ + 'source_id' => $sourceId, + 'synced_count' => $result['synced_count'], + 'skipped_count' => $result['skipped_count'], + ]); + } else { + // 处理失败,但不重试(避免重复数据) + $msg->ack(); + LoggerHelper::logError(new \RuntimeException("数据同步失败"), [ + 'component' => 'DataSyncWorker', + 'action' => 'processMessage', + 'source_id' => $sourceId, + 'result' => $result, + ]); + } + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'DataSyncWorker', + 'action' => 'processMessage', + 'source_id' => $sourceId, + 'payload' => $payload, + 'worker_id' => Worker::getCurrentWorker()->id, + ]); + + // 业务错误不重试,直接 ACK(避免重复数据) + // 系统错误(如数据库连接断开)可以考虑重试,这里简化处理为直接 ACK + $msg->ack(); + // 如果需要重试,可以 basic_reject($msg->delivery_info['delivery_tag'], true); + // 或者推送到死信队列 + } + } + + /** + * Worker 停止时关闭连接 + * + * @param Worker $worker Worker 实例 + * @return void + */ + public function onWorkerStop(Worker $worker): void + { + LoggerHelper::logBusiness('data_sync_worker_stopping', [ + 'worker_id' => $worker->id, + ]); + $this->closeConnection(); + } + + /** + * 关闭 RabbitMQ 连接和通道 + * + * @return void + */ + private function closeConnection(): void + { + try { + if ($this->channel) { + $this->channel->close(); + } + if ($this->connection && $this->connection->isConnected()) { + $this->connection->close(); + } + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'DataSyncWorker', + 'action' => 'closeConnection', + ]); + } finally { + $this->channel = null; + $this->connection = null; + } + } +} + diff --git a/app/process/TagCalculationWorker.php b/app/process/TagCalculationWorker.php new file mode 100644 index 0000000..3153e2c --- /dev/null +++ b/app/process/TagCalculationWorker.php @@ -0,0 +1,294 @@ +config = config('queue.connections.rabbitmq', []); + + // 使用 Timer 延迟初始化,避免阻塞 Worker 启动 + \Workerman\Timer::add(0, function () use ($worker) { + $this->initConnection($worker); + }, [], false); + } + + /** + * 初始化 RabbitMQ 连接 + * + * @param Worker $worker Worker 实例 + * @return void + */ + private function initConnection(Worker $worker): void + { + static $retryCount = 0; + $maxRetries = 10; + $retryInterval = 5; + + try { + // 清理之前的连接 + $this->closeConnection(); + + // 建立 RabbitMQ 连接 + $this->connection = new AMQPStreamConnection( + $this->config['host'], + $this->config['port'], + $this->config['user'], + $this->config['password'], + $this->config['vhost'], + false, // insist + 'AMQPLAIN', // login_method + null, // login_response + 'en_US', // locale + $this->config['timeout'] ?? 10.0, // connection_timeout + $this->config['timeout'] ?? 10.0, // read_write_timeout + null, // context + false, // keepalive + $this->config['heartbeat'] ?? 0 // heartbeat + ); + + $this->channel = $this->connection->channel(); + + // 声明队列(确保队列存在) + $queueConfig = $this->config['queues']['tag_calculation']; + $this->channel->queue_declare( + $queueConfig['name'], + false, // passive + $queueConfig['durable'], + false, // exclusive + $queueConfig['auto_delete'], + false, // nowait + $queueConfig['arguments'] ?? [] + ); + + // 设置 QoS(每次只处理一条消息) + $consumerConfig = config('queue.consumer.tag_calculation', []); + $this->channel->basic_qos( + null, // prefetch_size + $consumerConfig['prefetch_count'] ?? 1, // prefetch_count + false // global + ); + + // 开始消费消息 + $this->channel->basic_consume( + $queueConfig['name'], + '', // consumer_tag + false, // no_local + $consumerConfig['no_ack'] ?? false, // no_ack + false, // exclusive + false, // nowait + [$this, 'processMessage'] // callback + ); + + LoggerHelper::logBusiness('tag_calculation_worker_started', [ + 'queue' => $queueConfig['name'], + 'worker_id' => $worker->id, + ]); + + // 重置重试计数 + $retryCount = 0; + + // 监听消息(使用 Timer 定期检查,避免阻塞) + \Workerman\Timer::add(0.1, function () use ($worker) { + if ($this->channel && $this->channel->is_consuming()) { + try { + // 非阻塞方式检查消息 + $this->channel->wait(null, false, 0); // timeout 0,非阻塞 + } catch (\PhpAmqpLib\Exception\AMQPTimeoutException $e) { + // 超时是正常的,继续等待 + return; + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'TagCalculationWorker', + 'action' => 'channel_wait', + ]); + // 连接断开,重新初始化(使用当前 Worker) + $currentWorker = \Workerman\Worker::getCurrentWorker(); + if ($currentWorker) { + $this->initConnection($currentWorker); + } + } + } + }, [], true); // 持续执行 + + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'TagCalculationWorker', + 'action' => 'initConnection', + 'retry_count' => $retryCount, + ]); + + // 重试连接(最多重试10次) + if ($retryCount < $maxRetries) { + $retryCount++; + \Workerman\Timer::add($retryInterval, function () use ($worker) { + $this->initConnection($worker); + }, [], false); + } else { + LoggerHelper::logError(new \RuntimeException("RabbitMQ 连接失败,已达到最大重试次数"), [ + 'component' => 'TagCalculationWorker', + 'action' => 'initConnection', + 'max_retries' => $maxRetries, + ]); + } + } + } + + /** + * 关闭连接 + * + * @return void + */ + private function closeConnection(): void + { + try { + if ($this->channel !== null) { + $this->channel->close(); + $this->channel = null; + } + if ($this->connection !== null && $this->connection->isConnected()) { + $this->connection->close(); + $this->connection = null; + } + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'TagCalculationWorker', + 'action' => 'closeConnection', + ]); + } + } + + /** + * 处理消息 + * + * @param AMQPMessage $message + */ + public function processMessage(AMQPMessage $message): void + { + $startTime = microtime(true); + $deliveryTag = $message->getDeliveryTag(); + + try { + // 解析消息 + $body = $message->getBody(); + $data = json_decode($body, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \InvalidArgumentException('消息格式错误: ' . json_last_error_msg()); + } + + // 验证必要字段 + if (empty($data['user_id'])) { + throw new \InvalidArgumentException('消息缺少 user_id 字段'); + } + + $userId = (string)$data['user_id']; + $tagIds = $data['tag_ids'] ?? null; + $triggerType = $data['trigger_type'] ?? 'consumption_record'; + $recordId = $data['record_id'] ?? null; + + LoggerHelper::logBusiness('tag_calculation_message_received', [ + 'user_id' => $userId, + 'tag_ids' => $tagIds, + 'trigger_type' => $triggerType, + 'record_id' => $recordId, + ]); + + // 创建 TagService 实例 + $tagService = new TagService( + new TagDefinitionRepository(), + new UserProfileRepository(), + new UserTagRepository(), + new TagHistoryRepository(), + new SimpleRuleEngine() + ); + + // 执行标签计算 + $tags = $tagService->calculateTags($userId, $tagIds); + + $duration = microtime(true) - $startTime; + LoggerHelper::logBusiness('tag_calculation_completed', [ + 'user_id' => $userId, + 'updated_count' => count($tags), + 'duration' => $duration, + ]); + LoggerHelper::logPerformance('tag_calculation_async', $duration, [ + 'user_id' => $userId, + 'tag_count' => count($tags), + ]); + + // 确认消息(只有在 no_ack = false 时才需要) + $consumerConfig = config('queue.consumer.tag_calculation', []); + if (!($consumerConfig['no_ack'] ?? false)) { + $message->ack(); + } + } catch (\Throwable $e) { + $duration = microtime(true) - $startTime; + + LoggerHelper::logError($e, [ + 'component' => 'TagCalculationWorker', + 'action' => 'processMessage', + 'delivery_tag' => $deliveryTag, + 'duration' => $duration, + ]); + + // 根据错误类型决定是否重试 + // 如果是业务逻辑错误(如用户不存在),直接确认消息,不重试 + // 如果是系统错误(如数据库连接失败),可以重试 + if ($e instanceof \InvalidArgumentException) { + // 业务逻辑错误,确认消息,不重试 + $consumerConfig = config('queue.consumer.tag_calculation', []); + if (!($consumerConfig['no_ack'] ?? false)) { + $message->ack(); + } + } else { + // 系统错误,拒绝消息并重新入队(重试) + $message->nack(false, true); // requeue = true + } + } + } + + public function onWorkerStop(Worker $worker): void + { + $this->closeConnection(); + + LoggerHelper::logBusiness('tag_calculation_worker_stopped', [ + 'worker_id' => $worker->id, + ]); + } +} + diff --git a/app/repository/ConsumptionRecordRepository.php b/app/repository/ConsumptionRecordRepository.php new file mode 100644 index 0000000..9b705f7 --- /dev/null +++ b/app/repository/ConsumptionRecordRepository.php @@ -0,0 +1,90 @@ + + */ + protected $fillable = [ + 'record_id', + 'user_id', + 'consume_time', + 'amount', + 'actual_amount', + 'currency', + 'store_id', + 'status', + 'create_time', + ]; + + /** + * 字段类型转换 + * + * @var array + */ + protected $casts = [ + 'amount' => 'float', + 'actual_amount'=> 'float', + 'consume_time' => 'datetime', + 'create_time' => 'datetime', + 'status' => 'int', + ]; + + /** + * 禁用 Laravel 默认时间戳 + * + * @var bool + */ + public $timestamps = false; +} + + diff --git a/app/repository/DataCollectionTaskRepository.php b/app/repository/DataCollectionTaskRepository.php new file mode 100644 index 0000000..b2051ac --- /dev/null +++ b/app/repository/DataCollectionTaskRepository.php @@ -0,0 +1,112 @@ + + */ + protected $fillable = [ + 'task_id', + 'name', + 'description', + 'data_source_id', // 源数据源ID + 'database', // 源数据库 + 'collection', // 源集合(单集合模式) + 'collections', // 源集合列表(多集合模式) + 'target_data_source_id', // 目标数据源ID + 'target_database', // 目标数据库 + 'target_collection', // 目标集合 + 'target_type', // 目标类型:consumption_record(消费记录)、generic(通用集合)等 + 'mode', // batch: 批量采集, realtime: 实时监听 + 'field_mappings', // 字段映射配置(单集合模式) + 'collection_field_mappings', // 字段映射配置(多集合模式) + 'lookups', // 连表查询配置(单集合模式) + 'collection_lookups', // 连表查询配置(多集合模式) + 'filter_conditions', // 过滤条件 + 'schedule', // 调度配置 + 'status', // pending: 待启动, running: 运行中, paused: 已暂停, stopped: 已停止, error: 错误 + 'progress', // 进度信息 + 'statistics', // 统计信息 + 'created_by', + 'created_at', + 'updated_at', + ]; + + /** + * 字段类型转换 + * + * @var array + * + * 注意:MongoDB 原生支持数组类型,不需要对数组字段进行 cast + * 如果进行 cast,当数据已经是数组时,Laravel 可能会尝试使用 Json cast 导致错误 + */ + protected $casts = [ + // 数组字段不进行 cast,让 MongoDB 直接处理 + // 'field_mappings' => 'array', + // 'collection_field_mappings' => 'array', + // 'lookups' => 'array', + // 'collection_lookups' => 'array', + // 'filter_conditions' => 'array', + // 'schedule' => 'array', + // 'progress' => 'array', + // 'statistics' => 'array', + // 'collections' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + /** + * 启用 Laravel 默认时间戳 + * + * @var bool + */ + public $timestamps = true; +} + diff --git a/app/repository/DataSourceRepository.php b/app/repository/DataSourceRepository.php new file mode 100644 index 0000000..05f82f6 --- /dev/null +++ b/app/repository/DataSourceRepository.php @@ -0,0 +1,99 @@ + 'int', + 'options' => 'array', + 'status' => 'int', + 'is_tag_engine' => 'bool', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + public $timestamps = true; + + const CREATED_AT = 'created_at'; + const UPDATED_AT = 'updated_at'; + + /** + * 转换为配置格式(兼容原有的config格式) + * + * @return array + */ + public function toConfigArray(): array + { + $config = [ + 'type' => $this->type, + 'host' => $this->host, + 'port' => $this->port, + 'database' => $this->database, + ]; + + if ($this->username) { + $config['username'] = $this->username; + } + + if ($this->password) { + $config['password'] = $this->password; + } + + if ($this->auth_source) { + $config['auth_source'] = $this->auth_source; + } + + if ($this->options) { + $config['options'] = $this->options; + } + + return $config; + } +} + diff --git a/app/repository/StoreRepository.php b/app/repository/StoreRepository.php new file mode 100644 index 0000000..44b46a9 --- /dev/null +++ b/app/repository/StoreRepository.php @@ -0,0 +1,123 @@ + + */ + protected $fillable = [ + 'store_id', + 'store_code', + 'store_name', + 'store_type', + 'store_level', + 'industry_id', + 'industry_detail_id', + 'store_address', + 'store_province', + 'store_city', + 'store_district', + 'store_business_area', + 'store_longitude', + 'store_latitude', + 'store_phone', + 'status', + 'create_time', + 'update_time', + ]; + + /** + * 字段类型转换 + * + * @var array + */ + protected $casts = [ + 'store_longitude' => 'float', + 'store_latitude' => 'float', + 'status' => 'int', + 'create_time' => 'datetime', + 'update_time' => 'datetime', + ]; + + /** + * 禁用 Laravel 默认时间戳 + * + * @var bool + */ + public $timestamps = false; + + /** + * 根据门店名称查找门店 + * + * @param string $storeName 门店名称 + * @return StoreRepository|null + */ + public function findByStoreName(string $storeName): ?StoreRepository + { + return $this->newQuery() + ->where('store_name', $storeName) + ->where('status', 0) // 只查询正常状态的门店 + ->first(); + } + + /** + * 根据门店编码查找门店 + * + * @param string $storeCode 门店编码 + * @return StoreRepository|null + */ + public function findByStoreCode(string $storeCode): ?StoreRepository + { + return $this->newQuery() + ->where('store_code', $storeCode) + ->first(); + } +} + diff --git a/app/repository/TagCohortRepository.php b/app/repository/TagCohortRepository.php new file mode 100644 index 0000000..4bc4579 --- /dev/null +++ b/app/repository/TagCohortRepository.php @@ -0,0 +1,57 @@ + 'array', + 'user_ids' => 'array', + 'user_count' => 'int', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + public $timestamps = false; +} + diff --git a/app/repository/TagDefinitionRepository.php b/app/repository/TagDefinitionRepository.php new file mode 100644 index 0000000..3f9d9b4 --- /dev/null +++ b/app/repository/TagDefinitionRepository.php @@ -0,0 +1,64 @@ + 'array', + 'dependencies' => 'array', + 'priority' => 'int', + 'status' => 'int', + 'version' => 'int', + 'create_time' => 'datetime', + 'update_time' => 'datetime', + ]; + + public $timestamps = false; +} + + diff --git a/app/repository/TagHistoryRepository.php b/app/repository/TagHistoryRepository.php new file mode 100644 index 0000000..9f24ed5 --- /dev/null +++ b/app/repository/TagHistoryRepository.php @@ -0,0 +1,52 @@ + 'datetime', + ]; + + public $timestamps = false; +} + + diff --git a/app/repository/TagTaskExecutionRepository.php b/app/repository/TagTaskExecutionRepository.php new file mode 100644 index 0000000..a364592 --- /dev/null +++ b/app/repository/TagTaskExecutionRepository.php @@ -0,0 +1,86 @@ + + */ + protected $fillable = [ + 'execution_id', + 'task_id', + 'started_at', + 'finished_at', + 'status', // running, completed, failed, cancelled + 'processed_users', + 'success_count', + 'error_count', + 'error_message', + 'created_at', + ]; + + /** + * 字段类型转换 + * + * @var array + */ + protected $casts = [ + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + 'created_at' => 'datetime', + ]; + + /** + * 启用 Laravel 默认时间戳 + * + * @var bool + */ + public $timestamps = true; +} + diff --git a/app/repository/TagTaskRepository.php b/app/repository/TagTaskRepository.php new file mode 100644 index 0000000..b883f4c --- /dev/null +++ b/app/repository/TagTaskRepository.php @@ -0,0 +1,95 @@ + + */ + protected $fillable = [ + 'task_id', + 'name', + 'description', + 'task_type', // full: 全量计算, incremental: 增量计算, specific: 指定用户 + 'target_tag_ids', // 目标标签ID列表 + 'user_scope', // 用户范围配置 + 'schedule', // 调度配置 + 'config', // 高级配置 + 'status', // pending, running, paused, stopped, completed, error + 'progress', // 进度信息 + 'statistics', // 统计信息 + 'created_by', + 'created_at', + 'updated_at', + ]; + + /** + * 字段类型转换 + * + * @var array + */ + protected $casts = [ + 'target_tag_ids' => 'array', + 'user_scope' => 'array', + 'schedule' => 'array', + 'config' => 'array', + 'progress' => 'array', + 'statistics' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + /** + * 启用 Laravel 默认时间戳 + * + * @var bool + */ + public $timestamps = true; +} + diff --git a/app/repository/UserPhoneRelationRepository.php b/app/repository/UserPhoneRelationRepository.php new file mode 100644 index 0000000..a214ee2 --- /dev/null +++ b/app/repository/UserPhoneRelationRepository.php @@ -0,0 +1,168 @@ + + */ + protected $fillable = [ + 'relation_id', + 'phone_number', + 'phone_hash', + 'user_id', + 'effective_time', + 'expire_time', + 'is_active', + 'type', + 'is_verified', + 'source', + 'create_time', + 'update_time', + ]; + + /** + * 字段类型转换 + * + * @var array + */ + protected $casts = [ + 'effective_time' => 'datetime', + 'expire_time' => 'datetime', + 'is_active' => 'boolean', + 'is_verified' => 'boolean', + 'create_time' => 'datetime', + 'update_time' => 'datetime', + ]; + + /** + * 禁用 Laravel 默认的 created_at/updated_at + * + * 我们使用 create_time / update_time 字段。 + * + * @var bool + */ + public $timestamps = false; + + /** + * 根据 relation_id 获取关联记录 + * + * @param string $relationId + * @return self|null + */ + public function findByRelationId(string $relationId): ?self + { + /** @var Builder $query */ + $query = static::query(); + return $query->where('relation_id', $relationId)->first(); + } + + /** + * 根据手机号哈希查找当前有效的关联 + * + * @param string $phoneHash + * @param \DateTimeInterface|null $atTime 查询时间点(默认为当前时间) + * @return self|null + */ + public function findActiveByPhoneHash(string $phoneHash, ?\DateTimeInterface $atTime = null): ?self + { + $queryTime = $atTime ?? new \DateTimeImmutable('now'); + + /** @var Builder $query */ + $query = static::query(); + return $query->where('phone_hash', $phoneHash) + ->where('effective_time', '<=', $queryTime) + ->where(function($q) use ($queryTime) { + $q->whereNull('expire_time') + ->orWhere('expire_time', '>=', $queryTime); + }) + ->where('is_active', true) + ->orderBy('effective_time', 'desc') + ->first(); + } + + /** + * 根据用户ID查找所有手机号关联(当前有效) + * + * @param string $userId + * @param bool $includeHistory 是否包含历史记录 + * @return array + */ + public function findByUserId(string $userId, bool $includeHistory = false): array + { + /** @var Builder $query */ + $query = static::query(); + $query->where('user_id', $userId); + + if (!$includeHistory) { + $query->where('is_active', true) + ->whereNull('expire_time'); + } + + return $query->orderBy('effective_time', 'desc')->get()->all(); + } + + /** + * 根据手机号哈希查找所有历史关联记录 + * + * @param string $phoneHash + * @return array + */ + public function findHistoryByPhoneHash(string $phoneHash): array + { + /** @var Builder $query */ + $query = static::query(); + return $query->where('phone_hash', $phoneHash) + ->orderBy('effective_time', 'desc') + ->get() + ->all(); + } +} + diff --git a/app/repository/UserProfileRepository.php b/app/repository/UserProfileRepository.php new file mode 100644 index 0000000..3ab7ca8 --- /dev/null +++ b/app/repository/UserProfileRepository.php @@ -0,0 +1,227 @@ + + */ + protected $fillable = [ + 'user_id', + 'id_card_hash', + 'id_card_encrypted', + 'id_card_type', + 'name', + 'phone', + 'address', + 'email', + 'gender', + 'birthday', + 'total_amount', + 'total_count', + 'last_consume_time', + 'tags_update_time', + 'is_temporary', // 是否为临时人(true=临时人,false=正式人) + 'merged_from_user_id', // 如果是从临时人合并而来,记录原user_id + 'status', + 'create_time', + 'update_time', + ]; + + /** + * 字段类型转换 + * + * @var array + */ + protected $casts = [ + 'total_amount' => 'float', + 'total_count' => 'int', + 'last_consume_time' => 'datetime', + 'tags_update_time' => 'datetime', + 'birthday' => 'datetime', + 'is_temporary' => 'bool', + 'status' => 'int', + 'create_time' => 'datetime', + 'update_time' => 'datetime', + ]; + + /** + * 禁用 Laravel 默认的 created_at/updated_at + * + * 我们使用 create_time / update_time 字段。 + * + * @var bool + */ + public $timestamps = false; + + /** + * 根据 user_id 获取用户记录(不存在时返回 null) + */ + public function findByUserId(string $userId): ?self + { + /** @var Builder $query */ + $query = static::query(); + return $query->where('user_id', $userId)->first(); + } + + /** + * 创建或更新用户的基础统计信息 + * + * 仅用于标签系统第一阶段:更新总金额、总次数、最后消费时间。 + * + * @param string $userId + * @param float $amount 本次消费金额 + * @param \DateTimeInterface $consumeTime 消费时间 + */ + public function increaseStats(string $userId, float $amount, \DateTimeInterface $consumeTime): self + { + $now = new \DateTimeImmutable('now'); + + /** @var self|null $user */ + $user = $this->findByUserId($userId); + + if (!$user) { + $user = new self([ + 'user_id' => $userId, + 'total_amount' => $amount, + 'total_count' => 1, + 'last_consume_time'=> $consumeTime, + 'is_temporary' => true, // 默认创建为临时人 + 'status' => 0, + 'create_time' => $now, + 'update_time' => $now, + ]); + } else { + $user->total_amount = (float)$user->total_amount + $amount; + $user->total_count = (int)$user->total_count + 1; + // 只在消费时间更晚时更新 + if (!$user->last_consume_time || $consumeTime > $user->last_consume_time) { + $user->last_consume_time = $consumeTime; + } + $user->update_time = $now; + } + + $user->save(); + + return $user; + } + + /** + * 根据身份证哈希查找用户 + * + * @param string $idCardHash 身份证哈希值 + * @return self|null + */ + public function findByIdCardHash(string $idCardHash): ?self + { + return $this->newQuery() + ->where('id_card_hash', $idCardHash) + ->where('status', 0) + ->first(); + } + + /** + * 查找所有临时人 + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public function findTemporaryUsers() + { + return $this->newQuery() + ->where('is_temporary', true) + ->where('status', 0) + ->get(); + } + + /** + * 标记用户为正式人 + * + * @param string $userId + * @param string|null $idCardHash 身份证哈希 + * @param string|null $idCardEncrypted 加密的身份证 + * @param string|null $idCard 原始身份证号(用于提取基础信息,可选) + * @return bool + */ + public function markAsFormal(string $userId, ?string $idCardHash = null, ?string $idCardEncrypted = null, ?string $idCard = null): bool + { + $user = $this->findByUserId($userId); + if (!$user) { + return false; + } + + $user->is_temporary = false; + if ($idCardHash !== null) { + $user->id_card_hash = $idCardHash; + } + if ($idCardEncrypted !== null) { + $user->id_card_encrypted = $idCardEncrypted; + } + + // 如果有原始身份证号,自动提取基础信息(如果字段为空才更新) + if ($idCard !== null && !empty($idCard)) { + $idCardInfo = \app\utils\IdCardHelper::extractInfo($idCard); + if ($idCardInfo['birthday'] !== null && $user->birthday === null) { + $user->birthday = $idCardInfo['birthday']; + } + // 只有当性别解析成功且当前值为 null 时才更新(0 也被认为是未设置) + if ($idCardInfo['gender'] > 0 && ($user->gender === null || $user->gender === 0)) { + $user->gender = $idCardInfo['gender']; + } + } + + $user->update_time = new \DateTimeImmutable('now'); + return $user->save(); + } +} + + diff --git a/app/repository/UserTagRepository.php b/app/repository/UserTagRepository.php new file mode 100644 index 0000000..20d1cba --- /dev/null +++ b/app/repository/UserTagRepository.php @@ -0,0 +1,62 @@ + 'string', + 'confidence' => 'float', + 'effective_time' => 'datetime', + 'expire_time' => 'datetime', + 'create_time' => 'datetime', + 'update_time' => 'datetime', + ]; + + public $timestamps = false; +} + + diff --git a/app/service/ConsumptionService.php b/app/service/ConsumptionService.php new file mode 100644 index 0000000..65d1078 --- /dev/null +++ b/app/service/ConsumptionService.php @@ -0,0 +1,282 @@ + $payload + * @return array|null 如果手机号和身份证号都为空,返回null(跳过该记录) + */ + public function createRecord(array $payload): ?array + { + // 基础必填字段校验 + foreach (['amount', 'actual_amount', 'store_id', 'consume_time'] as $field) { + if (!isset($payload[$field]) || $payload[$field] === '') { + throw new \InvalidArgumentException("缺少必填字段:{$field}"); + } + } + + $amount = (float)$payload['amount']; + $actual = (float)$payload['actual_amount']; + $storeId = (string)$payload['store_id']; + $consumeTime = new \DateTimeImmutable((string)$payload['consume_time']); + + // 解析用户ID:优先使用user_id,如果没有则通过手机号/身份证解析 + $userId = null; + if (!empty($payload['user_id'])) { + $userId = (string)$payload['user_id']; + } else { + // 通过手机号或身份证解析用户ID + $phoneNumber = trim($payload['phone_number'] ?? ''); + $idCard = trim($payload['id_card'] ?? ''); + + // 如果手机号和身份证号都为空,直接跳过该记录 + if (empty($phoneNumber) && empty($idCard)) { + LoggerHelper::logBusiness('consumption_record_skipped_no_identifier', [ + 'reason' => 'phone_number and id_card are both empty', + 'consume_time' => $consumeTime->format('Y-m-d H:i:s'), + ]); + return null; + } + + // 传入 consume_time 作为查询时间点 + $userId = $this->identifierService->resolvePersonId($phoneNumber, $idCard, $consumeTime); + + // 如果同时提供了手机号和身份证,检查是否需要合并 + if (!empty($phoneNumber) && !empty($idCard)) { + $userId = $this->handleMergeIfNeeded($phoneNumber, $idCard, $userId, $consumeTime); + } + } + + $now = new \DateTimeImmutable('now'); + $recordId = UuidGenerator::uuid4()->toString(); + + // 写入消费记录 + $record = new ConsumptionRecordRepository(); + $record->record_id = $recordId; + $record->user_id = $userId; + $record->consume_time = $consumeTime; + $record->amount = $amount; + $record->actual_amount = $actual; + $record->currency = $payload['currency'] ?? 'CNY'; + $record->store_id = $storeId; + $record->status = 0; + $record->create_time = $now; + $record->save(); + + // 更新用户统计信息 + $user = $this->userProfileRepository->increaseStats($userId, $actual, $consumeTime); + + // 触发标签计算(异步方式) + $tags = []; + $useAsync = getenv('TAG_CALCULATION_ASYNC') !== 'false'; // 默认使用异步,可通过环境变量关闭 + + if ($useAsync) { + // 异步方式:推送到消息队列 + try { + $success = QueueService::pushTagCalculation([ + 'user_id' => $userId, + 'tag_ids' => null, // null 表示计算所有 real_time 标签 + 'trigger_type' => 'consumption_record', + 'record_id' => $recordId, + 'timestamp' => time(), + ]); + + if ($success) { + LoggerHelper::logBusiness('tag_calculation_queued', [ + 'user_id' => $userId, + 'record_id' => $recordId, + ]); + } else { + // 如果推送失败,降级到同步调用 + LoggerHelper::logBusiness('tag_calculation_queue_failed_fallback', [ + 'user_id' => $userId, + 'record_id' => $recordId, + ]); + $useAsync = false; + } + } catch (\Throwable $e) { + // 如果队列服务异常,降级到同步调用 + LoggerHelper::logError($e, [ + 'component' => 'ConsumptionService', + 'action' => 'pushTagCalculation', + 'user_id' => $userId, + ]); + $useAsync = false; + } + } + + // 同步方式(降级方案或配置关闭异步时使用) + if (!$useAsync && $this->tagService) { + try { + $tags = $this->tagService->calculateTags($userId); + } catch (\Throwable $e) { + // 标签计算失败不影响消费记录写入,只记录错误 + LoggerHelper::logError($e, [ + 'component' => 'ConsumptionService', + 'action' => 'calculateTags', + 'user_id' => $userId, + ]); + } + } + + return [ + 'record_id' => $recordId, + 'user_id' => $userId, + 'user' => [ + 'total_amount' => $user->total_amount ?? 0, + 'total_count' => $user->total_count ?? 0, + 'last_consume_time' => $user->last_consume_time, + ], + 'tags' => $tags, // 异步模式下为空数组,同步模式下包含标签信息 + 'tag_calculation_mode' => $useAsync ? 'async' : 'sync', + ]; + } + + /** + * 当手机号和身份证号同时出现时,检查是否需要合并用户 + * + * @param string $phoneNumber 手机号 + * @param string $idCard 身份证号 + * @param string $currentUserId 当前解析出的用户ID + * @param \DateTimeInterface $consumeTime 消费时间 + * @return string 最终使用的用户ID + */ + private function handleMergeIfNeeded( + string $phoneNumber, + string $idCard, + string $currentUserId, + \DateTimeInterface $consumeTime + ): string { + // 通过身份证查找用户 + $userIdByIdCard = $this->identifierService->resolvePersonIdByIdCard($idCard); + + // 在消费时间点查询手机号关联(使用反射或公共方法) + $userPhoneService = new \app\service\UserPhoneService( + new \app\repository\UserPhoneRelationRepository() + ); + $userIdByPhone = $userPhoneService->findUserByPhone($phoneNumber, $consumeTime); + + // 如果身份证找到用户A,手机号关联到用户B,且A≠B + if ($userIdByIdCard && $userIdByPhone && $userIdByIdCard !== $userIdByPhone) { + // 检查用户B是否为临时用户 + $userB = $this->userProfileRepository->findByUserId($userIdByPhone); + $userA = $this->userProfileRepository->findByUserId($userIdByIdCard); + + if ($userB && $userB->is_temporary) { + // 情况1:用户B是临时用户 → 合并到正式用户A + // 需要合并服务,动态创建 + $tagService = $this->tagService ?? new TagService( + new \app\repository\TagDefinitionRepository(), + $this->userProfileRepository, + new \app\repository\UserTagRepository(), + new \app\repository\TagHistoryRepository(), + new \app\service\TagRuleEngine\SimpleRuleEngine() + ); + + $mergeService = new PersonMergeService( + $this->userProfileRepository, + new \app\repository\UserTagRepository(), + $userPhoneService, + $tagService + ); + + // 合并临时用户B到正式用户A + $mergeService->mergeUsers($userIdByPhone, $userIdByIdCard); + + // 将旧的手机关联标记为过期(使用消费时间作为过期时间) + $userPhoneService->removePhoneFromUser($userIdByPhone, $phoneNumber, $consumeTime); + + // 建立新的手机关联到用户A(使用消费时间作为生效时间) + $userPhoneService->addPhoneToUser($userIdByIdCard, $phoneNumber, [ + 'source' => 'merge_after_id_card_binding', + 'effective_time' => $consumeTime, + 'type' => 'personal', + ]); + + LoggerHelper::logBusiness('auto_merge_triggered', [ + 'phone_number' => $phoneNumber, + 'source_user_id' => $userIdByPhone, + 'target_user_id' => $userIdByIdCard, + 'consume_time' => $consumeTime->format('Y-m-d H:i:s'), + 'reason' => 'temporary_user_merge', + ]); + + return $userIdByIdCard; + } elseif ($userA && !$userA->is_temporary && $userB && !$userB->is_temporary) { + // 情况2:两者都是正式用户(如酒店预订代订场景) + // 策略:以身份证为准,消费记录归属到身份证用户,但手机号关联保持不变 + // 原因:手机号和身份证同时出现时,身份证更可信;但手机号可能是代订,不应自动转移 + + // 检查手机号在消费时间点是否已经关联到用户A(可能之前已经转移过) + $phoneRelationAtTime = $userPhoneService->findUserByPhone($phoneNumber, $consumeTime); + + if ($phoneRelationAtTime !== $userIdByIdCard) { + // 手机号在消费时间点还未关联到身份证用户 + // 记录异常情况,但不强制转移手机号(可能是代订场景) + LoggerHelper::logBusiness('phone_id_card_mismatch_formal_users', [ + 'phone_number' => $phoneNumber, + 'phone_user_id' => $userIdByPhone, + 'id_card_user_id' => $userIdByIdCard, + 'consume_time' => $consumeTime->format('Y-m-d H:i:s'), + 'decision' => 'use_id_card_user', + 'note' => '正式用户冲突,以身份证为准(可能是代订场景)', + ]); + } + + // 以身份证用户为准,返回身份证用户ID + return $userIdByIdCard; + } else { + // 其他情况(理论上不应该发生),记录日志并返回身份证用户 + LoggerHelper::logBusiness('phone_id_card_mismatch_unknown', [ + 'phone_number' => $phoneNumber, + 'phone_user_id' => $userIdByPhone, + 'id_card_user_id' => $userIdByIdCard, + 'phone_user_is_temporary' => $userB ? $userB->is_temporary : 'unknown', + 'id_card_user_is_temporary' => $userA ? $userA->is_temporary : 'unknown', + 'consume_time' => $consumeTime->format('Y-m-d H:i:s'), + ]); + + // 默认返回身份证用户(更可信) + return $userIdByIdCard; + } + } + + return $currentUserId; + } + +} + + diff --git a/app/service/DataCollection/Handler/BaseCollectionHandler.php b/app/service/DataCollection/Handler/BaseCollectionHandler.php new file mode 100644 index 0000000..aa8a642 --- /dev/null +++ b/app/service/DataCollection/Handler/BaseCollectionHandler.php @@ -0,0 +1,115 @@ +dataSourceService = new DataSourceService( + new DataSourceRepository() + ); + + // 初始化公共服务(避免在子类中重复实例化) + $this->identifierService = new \app\service\IdentifierService( + new \app\repository\UserProfileRepository(), + new \app\service\UserPhoneService( + new \app\repository\UserPhoneRelationRepository() + ) + ); + + $this->consumptionService = new \app\service\ConsumptionService( + new \app\repository\ConsumptionRecordRepository(), + new \app\repository\UserProfileRepository(), + $this->identifierService + ); + + $this->storeService = new \app\service\StoreService( + new \app\repository\StoreRepository() + ); + } + + /** + * 获取 MongoDB 客户端 + * + * @param array $taskConfig 任务配置 + * @return Client MongoDB 客户端实例 + * @throws \InvalidArgumentException 如果数据源配置不存在 + */ + protected function getMongoClient(array $taskConfig): Client + { + $dataSourceId = $taskConfig['data_source_id'] + ?? $taskConfig['data_source'] + ?? 'sync_mongodb'; + + $dataSourceConfig = $this->dataSourceService->getDataSourceConfigById($dataSourceId); + + if (empty($dataSourceConfig)) { + throw new \InvalidArgumentException("数据源配置不存在: {$dataSourceId}"); + } + + return MongoDBHelper::createClient($dataSourceConfig); + } + + /** + * 连接到目标数据源 + * + * @param string $targetDataSourceId 目标数据源ID + * @param string|null $targetDatabase 目标数据库名(可选,默认使用数据源配置中的数据库) + * @return array{client: Client, database: \MongoDB\Database, dbName: string, config: array} 连接信息 + * @throws \InvalidArgumentException 如果目标数据源配置不存在 + */ + protected function connectToTargetDataSource( + string $targetDataSourceId, + ?string $targetDatabase = null + ): array { + $targetDataSourceConfig = $this->dataSourceService->getDataSourceConfigById($targetDataSourceId); + + if (empty($targetDataSourceConfig)) { + throw new \InvalidArgumentException("目标数据源配置不存在: {$targetDataSourceId}"); + } + + $client = MongoDBHelper::createClient($targetDataSourceConfig); + $dbName = $targetDatabase ?? $targetDataSourceConfig['database'] ?? 'ckb'; + $database = $client->selectDatabase($dbName); + + return [ + 'client' => $client, + 'database' => $database, + 'dbName' => $dbName, + 'config' => $targetDataSourceConfig, + ]; + } + + /** + * 采集数据(抽象方法,由子类实现) + * + * @param mixed $adapter 数据源适配器 + * @param array $taskConfig 任务配置 + * @return void + */ + abstract public function collect($adapter, array $taskConfig): void; +} + diff --git a/app/service/DataCollection/Handler/ConsumptionCollectionHandler.php b/app/service/DataCollection/Handler/ConsumptionCollectionHandler.php new file mode 100644 index 0000000..b4fd403 --- /dev/null +++ b/app/service/DataCollection/Handler/ConsumptionCollectionHandler.php @@ -0,0 +1,1760 @@ +taskService = new \app\service\DataCollectionTaskService( + new \app\repository\DataCollectionTaskRepository() + ); + } + + /** + * 采集消费记录 + * + * @param \app\service\DataSource\DataSourceAdapterInterface $adapter 数据源适配器 + * @param array $taskConfig 任务配置 + * @return void + */ + public function collect($adapter, array $taskConfig): void + { + $this->taskConfig = $taskConfig; + $taskId = $taskConfig['task_id'] ?? ''; + $taskName = $taskConfig['name'] ?? '消费记录采集'; + $sourceType = $taskConfig['source_type'] ?? 'kr_mall'; // kr_mall, kr_finance + $mode = $taskConfig['mode'] ?? 'batch'; // batch: 批量采集, realtime: 实时监听 + + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤5-Handler开始】任务ID={$taskId}, 任务名称={$taskName}, 数据源类型={$sourceType}, 模式={$mode}\n"); + LoggerHelper::logBusiness('consumption_collection_started', [ + 'task_id' => $taskId, + 'task_name' => $taskName, + 'source_type' => $sourceType, + 'mode' => $mode, + ]); + + try { + // 根据模式执行不同的采集逻辑 + if ($mode === 'realtime') { + // 实时监听模式 + switch ($sourceType) { + case 'kr_mall': + $this->watchKrMallCollection($taskConfig); + break; + case 'kr_finance': + $this->watchKrFinanceCollections($taskConfig); + break; + default: + throw new \InvalidArgumentException("不支持的数据源类型: {$sourceType}"); + } + } else { + // 批量采集模式 + switch ($sourceType) { + case 'kr_mall': + $this->collectFromKrMall($adapter, $taskConfig); + break; + case 'kr_finance': + $this->collectFromKrFinance($adapter, $taskConfig); + break; + default: + throw new \InvalidArgumentException("不支持的数据源类型: {$sourceType}"); + } + + LoggerHelper::logBusiness('consumption_collection_completed', [ + 'task_id' => $taskId, + 'task_name' => $taskName, + ]); + } + + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'ConsumptionCollectionHandler', + 'action' => 'collect', + 'task_id' => $taskId, + ]); + throw $e; + } + } + + /** + * 从KR_商城数据库采集订单数据 + * + * @param mixed $adapter 数据源适配器 + * @param array $taskConfig 任务配置 + * @return void + */ + private function collectFromKrMall($adapter, array $taskConfig): void + { + $taskId = $taskConfig['task_id'] ?? ''; + $databaseName = $taskConfig['database'] ?? 'KR_商城'; + $collectionName = $taskConfig['collection'] ?? '21年贝蒂喜订单整合'; + $lastSyncTime = $taskConfig['last_sync_time'] ?? null; + $batchSize = $taskConfig['batch_size'] ?? 1000; + + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤6-连接源数据库】开始连接: database={$databaseName}, collection={$collectionName}\n"); + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤6-连接源数据库】任务配置来源: task_id={$taskId}\n"); + LoggerHelper::logBusiness('kr_mall_collection_start', [ + 'database' => $databaseName, + 'collection' => $collectionName, + ]); + + // 获取MongoDB客户端和数据库 + $client = $this->getMongoClient($taskConfig); + $database = $client->selectDatabase($databaseName); + $collection = $database->selectCollection($collectionName); + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤6-连接源数据库】✓ 源数据库连接成功\n"); + + // 构建查询条件(如果有上次同步时间,只查询新数据) + $filter = []; + if ($lastSyncTime !== null) { + $lastSyncTimestamp = is_numeric($lastSyncTime) ? (int)$lastSyncTime : strtotime($lastSyncTime); + $lastSyncDate = new \MongoDB\BSON\UTCDateTime($lastSyncTimestamp * 1000); + $filter['订单创建时间'] = ['$gt' => $lastSyncDate]; + } + + // 获取总数(用于计算进度) + $totalCount = $collection->countDocuments($filter); + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤7-统计总数】总记录数: {$totalCount}\n"); + + // 计算进度更新间隔(根据总数动态调整) + $updateInterval = $this->calculateProgressUpdateInterval($totalCount); + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤7-统计总数】进度更新间隔: 每 {$updateInterval} 条更新一次\n"); + + // 更新进度:开始采集 + // 注意:任务状态已经在 startTask 方法中设置为 running,这里不需要再次更新状态 + // 只需要更新进度信息(start_time, total_count等) + if (!empty($taskId)) { + $this->updateProgress($taskId, [ + // 不更新 status,因为 startTask 已经设置为 running + 'start_time' => new \MongoDB\BSON\UTCDateTime(time() * 1000), + 'total_count' => $totalCount, + 'processed_count' => 0, + 'success_count' => 0, + 'error_count' => 0, + 'percentage' => 0, + ]); + } + + // 分页查询 + $offset = 0; + $processedCount = 0; + $successCount = 0; + $errorCount = 0; + $lastUpdateCount = 0; // 记录上次更新的处理数量 + $isCompleted = false; // 标记是否已完成 + + do { + $cursor = $collection->find( + $filter, + [ + 'limit' => $batchSize, + 'skip' => $offset, + 'sort' => ['订单创建时间' => 1], + ] + ); + + $batch = []; + foreach ($cursor as $doc) { + $batch[] = $this->convertMongoDocumentToArray($doc); + } + + if (empty($batch)) { + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤8-查询数据】批次为空,结束查询\n"); + break; + } + + $batchCount = count($batch); + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤8-查询数据】查询到 {$batchCount} 条数据,offset={$offset}\n"); + + // 获取任务ID + $taskId = $taskConfig['task_id'] ?? ''; + + // 检查任务状态(在批次处理前) + if (!empty($taskId) && !$this->checkTaskStatus($taskId)) { + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] ⚠️ 任务已暂停或停止,停止采集\n"); + break; + } + + // 如果已完成,不再处理 + if ($isCompleted) { + break; + } + + // 处理批量数据 + foreach ($batch as $index => $orderData) { + // 每10条检查一次任务状态 + if (!empty($taskId) && ($index + 1) % 10 === 0) { + if (!$this->checkTaskStatus($taskId)) { + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] ⚠️ 任务已暂停或停止,停止处理剩余数据\n"); + break 2; // 跳出两层循环(foreach 和 do-while) + } + } + + // 检查是否已达到总数(在每条处理前检查,避免超出) + // 注意:检查在递增之前,如果 processedCount == totalCount - 1,会继续处理一条 + // 然后 processedCount 变成 totalCount,下次循环时会 break + if ($totalCount > 0 && $processedCount >= $totalCount) { + $isCompleted = true; + break; // 跳出当前批次处理循环 + } + + $processedCount++; + $orderNo = $orderData['订单编号'] ?? 'unknown'; + + try { + // 每10条输出一次简要进度 + if (($index + 1) % 10 === 0) { + // \Workerman\Worker::safeEcho(" ⏳ 批量处理进度: {$processedCount} / {$batchCount} (本批次) | 总成功: {$successCount} | 总失败: {$errorCount}\n"); + } + + $this->processKrMallOrder($orderData, $taskConfig); + $successCount++; + } catch (\Exception $e) { + $errorCount++; + $errorMsg = $e->getMessage(); + // \Workerman\Worker::safeEcho(" ❌ [订单编号: {$orderNo}] 处理失败: {$errorMsg}\n"); + LoggerHelper::logError($e, [ + 'component' => 'ConsumptionCollectionHandler', + 'action' => 'processKrMallOrder', + 'order_no' => $orderNo, + ]); + } + } + + // 批次处理完成,根据更新间隔决定是否更新进度 + if (!empty($taskId) && $totalCount > 0) { + // 只有当处理数量达到更新间隔时才更新进度 + if (($processedCount - $lastUpdateCount) >= $updateInterval || $processedCount >= $totalCount) { + $percentage = round(($processedCount / $totalCount) * 100, 2); + + // 检查是否达到100% + if ($processedCount >= $totalCount) { + // 进度达到100%,停止采集并更新状态为已完成 + $this->updateProgress($taskId, [ + 'status' => 'completed', + 'processed_count' => $processedCount, + 'success_count' => $successCount, + 'error_count' => $errorCount, + 'percentage' => 100, + 'end_time' => new \MongoDB\BSON\UTCDateTime(time() * 1000), + ]); + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] ✅ 采集完成,进度已达到100%,已停止采集\n"); + $isCompleted = true; // 标记为已完成 + } else { + $this->updateProgress($taskId, [ + 'total_count' => $totalCount, // 确保每次更新都包含 total_count + 'processed_count' => $processedCount, + 'success_count' => $successCount, + 'error_count' => $errorCount, + 'percentage' => $percentage, + ]); + } + $lastUpdateCount = $processedCount; + } + } + + // 批次处理完成,输出统计 + if ($batchCount > 0) { + // \Workerman\Worker::safeEcho(" 📊 本批次完成: 总数={$batchCount}, 成功={$successCount}, 失败={$errorCount}\n"); + } + + $offset += $batchSize; + + LoggerHelper::logBusiness('kr_mall_collection_batch_processed', [ + 'processed' => $processedCount, + 'success' => $successCount, + 'error' => $errorCount, + 'offset' => $offset, + ]); + + } while (count($batch) === $batchSize && !$isCompleted); + + // 更新进度:采集完成(如果循环正常结束,也更新状态为已完成) + if (!empty($taskId)) { + $percentage = $totalCount > 0 ? round(($processedCount / $totalCount) * 100, 2) : 100; + // 获取当前任务状态 + $task = $this->taskService->getTask($taskId); + if ($task) { + // 只有在任务状态不是 completed、paused、stopped 时,才更新为 completed + // 如果任务被暂停或停止,不应该更新为 completed + if ($task['status'] === 'completed') { + // 已经是 completed,不需要更新 + } elseif (in_array($task['status'], ['paused', 'stopped'])) { + // 任务被暂停或停止,只更新进度,不更新状态 + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] ⚠️ 任务已被暂停或停止,不更新为completed状态\n"); + $this->updateProgress($taskId, [ + 'total_count' => $totalCount, + 'processed_count' => $processedCount, + 'success_count' => $successCount, + 'error_count' => $errorCount, + 'percentage' => $percentage, + ]); + } else { + // 任务正常完成,更新状态为 completed + $this->updateProgress($taskId, [ + 'status' => 'completed', + 'total_count' => $totalCount, + 'processed_count' => $processedCount, + 'success_count' => $successCount, + 'error_count' => $errorCount, + 'percentage' => 100, // 完成时强制设置为100% + 'end_time' => new \MongoDB\BSON\UTCDateTime(time() * 1000), + ]); + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] ✅ 采集任务完成,状态已更新为completed\n"); + } + } + } + + LoggerHelper::logBusiness('kr_mall_collection_completed', [ + 'total_processed' => $processedCount, + 'total_success' => $successCount, + 'total_error' => $errorCount, + ]); + } + + /** + * 处理KR_商城订单数据 + * + * @param array $orderData 订单数据 + * @param array $taskConfig 任务配置 + * @return void + */ + private function processKrMallOrder(array $orderData, array $taskConfig): void + { + $orderNo = $orderData['订单编号'] ?? 'unknown'; + + // 1. 提取手机号(优先使用支付宝账号,其次是收货人电话) + $phoneNumber = $this->extractPhoneNumber($orderData); + if (empty($phoneNumber)) { + LoggerHelper::logBusiness('consumption_collection_skip_no_phone', [ + 'order_no' => $orderNo, + 'reason' => '无法提取手机号', + ]); + // \Workerman\Worker::safeEcho(" ⚠️ [订单编号: {$orderNo}] 跳过:无法提取手机号\n"); + return; // 跳过无法提取手机号的记录 + } + + // 2. 字段映射和转换 + $consumeRecord = $this->transformKrMallOrder($orderData, $phoneNumber, $taskConfig); + + // 3. 写入消费记录(会在saveConsumptionRecord中输出详细的流水信息) + $this->saveConsumptionRecord($consumeRecord); + } + + /** + * 转换KR_商城订单数据为标准消费记录格式 + * + * @param array $orderData 订单数据 + * @param string $phoneNumber 手机号 + * @param array $taskConfig 任务配置 + * @return array 标准消费记录数据 + */ + private function transformKrMallOrder(array $orderData, string $phoneNumber, array $taskConfig): array + { + // 首先应用字段映射(从任务配置中读取字段映射) + $fieldMappings = $taskConfig['field_mappings'] ?? []; + + // 调试:输出字段映射配置和源数据字段 + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤9-字段映射】字段映射配置数量: " . count($fieldMappings) . "\n"); + if (!empty($fieldMappings)) { + foreach ($fieldMappings as $idx => $mapping) { + $targetField = $mapping['target_field'] ?? ''; + $sourceField = $mapping['source_field'] ?? ''; + if ($targetField === 'store_name') { + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤9-字段映射】找到store_name映射: target={$targetField}, source={$sourceField}\n"); + } + } + } + + // 调试:输出源数据中的字段名(用于排查) + $sourceFields = array_keys($orderData); + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤9-字段映射】源数据字段列表: " . implode(', ', array_slice($sourceFields, 0, 20)) . (count($sourceFields) > 20 ? '...' : '') . "\n"); + + $mappedData = $this->applyFieldMappings($orderData, $fieldMappings); + + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤9-字段映射】应用字段映射完成,映射字段数: " . count($mappedData) . ", 映射后的字段: " . implode(', ', array_keys($mappedData)) . "\n"); + + // 从映射后的数据中获取字段值(如果没有映射,使用默认值或从源数据获取) + // 消费时间:从字段映射中获取,如果没有则尝试从源数据获取 + $consumeTimeStr = $mappedData['consume_time'] ?? null; + if (empty($consumeTimeStr)) { + // 后备方案:尝试从源数据中获取(向后兼容) + $consumeTimeStr = $orderData['订单付款时间'] ?? $orderData['订单创建时间'] ?? null; + } + $consumeTime = $this->parseDateTime($consumeTimeStr); + if ($consumeTime === null) { + throw new \InvalidArgumentException('无法解析消费时间'); + } + + // 金额:从字段映射中获取 + $totalAmount = $this->parseAmount($mappedData['amount'] ?? '0'); + $actualAmount = $this->parseAmount($mappedData['actual_amount'] ?? $totalAmount); + $discountAmount = $totalAmount - $actualAmount; + + // 积分抵扣:从字段映射中获取(如果有) + $pointsDeduction = 0; + if (isset($mappedData['points_deduction']) && !empty($mappedData['points_deduction'])) { + $pointsDeduction = $this->parseAmount($mappedData['points_deduction']); + } + + // 门店名称:从字段映射中获取,优先保存原始门店名称 + $storeName = $mappedData['store_name'] ?? null; + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤9-字段映射】映射后的store_name值: " . ($storeName ?? 'null') . "\n"); + + if (empty($storeName)) { + // 后备方案:尝试从源数据中获取(向后兼容) + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤9-字段映射】store_name为空,尝试从源数据中查找\n"); + // 尝试多个可能的字段名 + $possibleStoreNameFields = ['新零售成交门店昵称', '门店名称', '店铺名称', '门店名', '店铺名', 'store_name', 'storeName', '门店', '店铺']; + foreach ($possibleStoreNameFields as $fieldName) { + if (isset($orderData[$fieldName]) && !empty($orderData[$fieldName])) { + $storeName = $orderData[$fieldName]; + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤9-字段映射】从源数据中找到门店名称: {$fieldName} = {$storeName}\n"); + break; + } + } + if (empty($storeName)) { + $storeName = 'KR_商城_在线店铺'; // 默认值 + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤9-字段映射】未找到门店名称字段,使用默认值: {$storeName}\n"); + } + } + + // 门店ID:通过门店服务获取或创建(即使失败也不影响门店名称的保存) + $storeId = $mappedData['store_id'] ?? null; + if (empty($storeId) && !empty($storeName)) { + try { + $source = $taskConfig['data_source_id'] ?? $taskConfig['name'] ?? 'KR_商城'; + $storeId = $this->storeService->getOrCreateStoreByName($storeName, $source); + } catch (\Throwable $e) { + // 店铺ID获取失败不影响门店名称的保存,只记录日志 + LoggerHelper::logError($e, [ + 'component' => 'ConsumptionCollectionHandler', + 'action' => 'transformKrMallOrder', + 'message' => '获取店铺ID失败,但会继续保存门店名称', + 'store_name' => $storeName, + ]); + } + } + + // 支付方式:从字段映射中获取 + $paymentMethodCode = $mappedData['payment_method_code'] ?? null; + if (empty($paymentMethodCode)) { + // 后备方案:从源数据解析(向后兼容) + $paymentMethodCode = $this->parsePaymentMethod($orderData['支付详情'] ?? ''); + } + + // 支付状态:从字段映射中获取,如果没有则从订单状态解析 + $paymentStatus = $mappedData['payment_status'] ?? null; + if ($paymentStatus === null) { + // 后备方案:从源数据解析(向后兼容) + $paymentStatus = $this->parsePaymentStatus($orderData['订单状态'] ?? ''); + } + + // 消费渠道:从字段映射中获取 + $consumeChannel = $mappedData['consume_channel'] ?? null; + if (empty($consumeChannel)) { + // 后备方案:从源数据解析(向后兼容) + $consumeChannel = $this->parseConsumeChannel($orderData['是否手机订单'] ?? ''); + } + + // 消费时段 + $consumePeriod = $this->parseConsumePeriod($consumeTime); + + // 原始订单ID:从字段映射中获取 + $sourceOrderId = $mappedData['source_order_id'] ?? null; + if (empty($sourceOrderId)) { + // 后备方案:从源数据获取(向后兼容) + $sourceOrderId = $orderData['订单编号'] ?? null; + } + + // 币种:从字段映射中获取,默认为CNY + $currency = $mappedData['currency'] ?? 'CNY'; + + // 状态:从字段映射中获取,默认为0(正常) + $status = $mappedData['status'] ?? 0; + + // 支付单号:从字段映射中获取 + $paymentTransactionId = $mappedData['payment_transaction_id'] ?? null; + if (empty($paymentTransactionId)) { + // 后备方案:从源数据获取(向后兼容) + $paymentTransactionId = $orderData['支付单号'] ?? null; + } + + return [ + 'phone_number' => $phoneNumber, // 传递手机号,让ConsumptionService解析user_id + 'consume_time' => $consumeTime->format('Y-m-d H:i:s'), + 'amount' => $totalAmount, + 'actual_amount' => $actualAmount, + 'discount_amount' => $discountAmount > 0 ? $discountAmount : null, + 'points_deduction' => $pointsDeduction > 0 ? $pointsDeduction : null, + 'currency' => $currency, + 'store_id' => $storeId, + 'store_name' => $storeName, // 保存门店名称,用于去重和展示 + 'payment_method_code' => $paymentMethodCode, + 'payment_channel' => $paymentMethodCode === 'alipay' ? '支付宝' : '其他', + 'payment_transaction_id' => $paymentTransactionId, + 'payment_status' => $paymentStatus, + 'consume_channel' => $consumeChannel, + 'consume_period' => $consumePeriod, + 'is_workday' => $this->isWorkday($consumeTime) ? 1 : 0, + 'source_order_id' => $sourceOrderId, // 原始订单ID,用于去重 + 'status' => $status, + ]; + } + + /** + * 从KR数据库采集金融贷款数据 + * + * @param mixed $adapter 数据源适配器 + * @param array $taskConfig 任务配置 + * @return void + */ + private function collectFromKrFinance($adapter, array $taskConfig): void + { + $taskId = $taskConfig['task_id'] ?? ''; + $databaseName = $taskConfig['database'] ?? 'KR'; + $collections = $taskConfig['collections'] ?? [ + '金融客户_厦门_A级用户', + '金融客户_厦门_B级用户', + '金融客户_厦门_C级用户', + '金融客户_厦门_D级用户', + '金融客户_厦门_E级用户', + '厦门用户资产2025年9月_优化版', + ]; + + LoggerHelper::logBusiness('kr_finance_collection_start', [ + 'database' => $databaseName, + 'collections' => $collections, + ]); + + $client = $this->getMongoClient($taskConfig); + $database = $client->selectDatabase($databaseName); + + // 计算总数(遍历所有集合) + $totalCount = 0; + foreach ($collections as $collectionName) { + $collection = $database->selectCollection($collectionName); + $totalCount += $collection->countDocuments([ + 'loan_amount' => ['$exists' => true, '$ne' => null, '$ne' => ''], + ]); + } + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤7-统计总数】总记录数: {$totalCount}\n"); + + // 计算进度更新间隔(根据总数动态调整) + $updateInterval = $this->calculateProgressUpdateInterval($totalCount); + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤7-统计总数】进度更新间隔: 每 {$updateInterval} 条更新一次\n"); + + // 更新进度:开始采集 + if (!empty($taskId)) { + $this->updateProgress($taskId, [ + 'status' => 'running', + 'start_time' => new \MongoDB\BSON\UTCDateTime(time() * 1000), + 'total_count' => $totalCount, + 'processed_count' => 0, + 'success_count' => 0, + 'error_count' => 0, + 'percentage' => 0, + ]); + } + + $processedCount = 0; + $successCount = 0; + $errorCount = 0; + $lastUpdateCount = 0; // 记录上次更新的处理数量 + $isCompleted = false; // 标记是否已完成 + + foreach ($collections as $collectionName) { + // 如果已完成,不再处理 + if ($isCompleted) { + break; + } + + try { + $collection = $database->selectCollection($collectionName); + + // 查询有loan_amount的记录 + $cursor = $collection->find([ + 'loan_amount' => ['$exists' => true, '$ne' => null, '$ne' => ''], + ]); + + foreach ($cursor as $doc) { + // 检查任务状态 + if (!empty($taskId) && !$this->checkTaskStatus($taskId)) { + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] ⚠️ 任务已暂停或停止,停止采集\n"); + $isCompleted = true; + break 2; // 跳出两层循环 + } + + // 检查是否已达到总数(在每条处理前检查,避免超出) + if ($totalCount > 0 && $processedCount >= $totalCount) { + $isCompleted = true; + break 2; // 跳出两层循环 + } + + $processedCount++; + try { + $financeData = $this->convertMongoDocumentToArray($doc); + $this->processKrFinanceRecord($financeData, $collectionName, $taskConfig); + $successCount++; + + // 根据更新间隔更新进度 + if (!empty($taskId)) { + // 如果totalCount为0,也要更新进度(显示已处理数量) + if ($totalCount == 0 || ($processedCount - $lastUpdateCount) >= $updateInterval || $processedCount >= $totalCount) { + if ($totalCount > 0) { + $percentage = round(($processedCount / $totalCount) * 100, 2); + } else { + $percentage = 0; // 总数未知时,百分比为0 + } + + // 检查是否达到100% + if ($totalCount > 0 && $processedCount >= $totalCount) { + // 进度达到100%,停止采集并更新状态为已完成 + $this->updateProgress($taskId, [ + 'status' => 'completed', + 'total_count' => $totalCount, + 'processed_count' => $processedCount, + 'success_count' => $successCount, + 'error_count' => $errorCount, + 'percentage' => 100, + 'end_time' => new \MongoDB\BSON\UTCDateTime(time() * 1000), + ]); + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] ✅ 采集完成,进度已达到100%,已停止采集\n"); + $isCompleted = true; // 标记为已完成 + break 2; // 跳出两层循环 + } else { + $this->updateProgress($taskId, [ + 'total_count' => $totalCount, + 'processed_count' => $processedCount, + 'success_count' => $successCount, + 'error_count' => $errorCount, + 'percentage' => $percentage, + ]); + } + $lastUpdateCount = $processedCount; + } + } + } catch (\Exception $e) { + $errorCount++; + LoggerHelper::logError($e, [ + 'component' => 'ConsumptionCollectionHandler', + 'action' => 'processKrFinanceRecord', + 'collection' => $collectionName, + ]); + } + } + } catch (\Exception $e) { + LoggerHelper::logError($e, [ + 'component' => 'ConsumptionCollectionHandler', + 'action' => 'collectFromKrFinance', + 'collection' => $collectionName, + ]); + } + } + + // 更新进度:采集完成(如果循环正常结束,也更新状态为已完成) + if (!empty($taskId)) { + $percentage = $totalCount > 0 ? round(($processedCount / $totalCount) * 100, 2) : 100; + // 获取当前任务状态 + $task = $this->taskService->getTask($taskId); + if ($task) { + // 只有在任务状态不是 completed、paused、stopped 时,才更新为 completed + // 如果任务被暂停或停止,不应该更新为 completed + if ($task['status'] === 'completed') { + // 已经是 completed,不需要更新 + } elseif (in_array($task['status'], ['paused', 'stopped'])) { + // 任务被暂停或停止,只更新进度,不更新状态 + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] ⚠️ 任务已被暂停或停止,不更新为completed状态\n"); + $this->updateProgress($taskId, [ + 'total_count' => $totalCount, + 'processed_count' => $processedCount, + 'success_count' => $successCount, + 'error_count' => $errorCount, + 'percentage' => $percentage, + ]); + } else { + // 任务正常完成,更新状态为 completed + $this->updateProgress($taskId, [ + 'status' => 'completed', + 'total_count' => $totalCount, + 'processed_count' => $processedCount, + 'success_count' => $successCount, + 'error_count' => $errorCount, + 'percentage' => 100, // 完成时强制设置为100% + 'end_time' => new \MongoDB\BSON\UTCDateTime(time() * 1000), + ]); + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] ✅ 采集任务完成,状态已更新为completed\n"); + } + } + } + + LoggerHelper::logBusiness('kr_finance_collection_completed', [ + 'total_processed' => $processedCount, + 'total_success' => $successCount, + 'total_error' => $errorCount, + ]); + } + + /** + * 处理KR金融记录数据 + * + * @param array $financeData 金融数据 + * @param string $collectionName 集合名称 + * @param array $taskConfig 任务配置 + * @return void + */ + private function processKrFinanceRecord(array $financeData, string $collectionName, array $taskConfig): void + { + // 1. 提取手机号 + $phoneNumber = $financeData['mobile'] ?? null; + if (empty($phoneNumber)) { + LoggerHelper::logBusiness('consumption_collection_skip_no_phone', [ + 'collection' => $collectionName, + 'reason' => '无法提取手机号', + ]); + return; // 跳过无法提取手机号的记录 + } + + // 2. 字段映射和转换 + $consumeRecord = $this->transformKrFinanceRecord($financeData, $phoneNumber, $collectionName, $taskConfig); + + // 3. 写入消费记录 + $this->saveConsumptionRecord($consumeRecord); + } + + /** + * 转换KR金融记录数据为标准消费记录格式 + * + * @param array $financeData 金融数据 + * @param string $phoneNumber 手机号 + * @param string $collectionName 集合名称 + * @param array $taskConfig 任务配置 + * @return array 标准消费记录数据 + */ + private function transformKrFinanceRecord( + array $financeData, + string $phoneNumber, + string $collectionName, + array $taskConfig + ): array { + // 首先应用字段映射(从任务配置中读取字段映射) + $fieldMappings = $taskConfig['field_mappings'] ?? []; + $mappedData = $this->applyFieldMappings($financeData, $fieldMappings); + + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤9-字段映射】应用字段映射完成,映射字段数: " . count($mappedData) . "\n"); + + // 从映射后的数据中获取字段值 + // 贷款金额作为消费金额:从字段映射中获取 + $loanAmount = $this->parseAmount($mappedData['amount'] ?? '0'); + if ($loanAmount <= 0) { + // 后备方案:从源数据获取(向后兼容) + $loanAmount = $this->parseAmount($financeData['loan_amount'] ?? '0'); + if ($loanAmount <= 0) { + throw new \InvalidArgumentException('贷款金额无效'); + } + } + + // 消费时间:从字段映射中获取 + $consumeTimeStr = $mappedData['consume_time'] ?? null; + if (empty($consumeTimeStr)) { + // 后备方案:从源数据获取(向后兼容) + $consumeTimeStr = $financeData['借款日期'] ?? null; + } + $consumeTime = $this->parseDateTime($consumeTimeStr); + if ($consumeTime === null) { + $consumeTime = new \DateTimeImmutable('now'); + } + + // 门店名称:从字段映射中获取 + $storeName = $mappedData['store_name'] ?? "未知门店"; + + + // 门店ID:通过门店服务获取或创建(即使失败也不影响门店名称的保存) + $storeId = $mappedData['store_id'] ?? null; + if (empty($storeId) && !empty($storeName)) { + try { + $source = $taskConfig['data_source_id'] ?? $taskConfig['name'] ?? 'KR_金融'; + $storeId = $this->storeService->getOrCreateStoreByName($storeName, $source); + } catch (\Throwable $e) { + // 店铺ID获取失败不影响门店名称的保存,只记录日志 + LoggerHelper::logError($e, [ + 'component' => 'ConsumptionCollectionHandler', + 'action' => 'transformKrFinanceRecord', + 'message' => '获取店铺ID失败,但会继续保存门店名称', + 'store_name' => $storeName, + ]); + } + } + + // 消费时段 + $consumePeriod = $this->parseConsumePeriod($consumeTime); + + // 币种:从字段映射中获取,默认为CNY + $currency = $mappedData['currency'] ?? 'CNY'; + + // 状态:从字段映射中获取,默认为0(正常) + $status = $mappedData['status'] ?? 0; + + // 支付方式:从字段映射中获取,默认为finance_loan + $paymentMethodCode = $mappedData['payment_method_code'] ?? 'finance_loan'; + + // 支付渠道:从字段映射中获取,默认为金融 + $paymentChannel = $mappedData['payment_channel'] ?? '金融'; + + // 支付状态:从字段映射中获取,默认为0(成功) + $paymentStatus = $mappedData['payment_status'] ?? 0; + + // 消费渠道:从字段映射中获取,默认为线下 + $consumeChannel = $mappedData['consume_channel'] ?? '线下'; + + return [ + 'phone_number' => $phoneNumber, // 传递手机号,让ConsumptionService解析user_id + 'consume_time' => $consumeTime->format('Y-m-d H:i:s'), + 'amount' => $loanAmount, + 'actual_amount' => $loanAmount, // 金融贷款,实际金额等于贷款金额 + 'currency' => $currency, + 'store_id' => $storeId, + 'store_name' => $storeName, // 保存门店名称,用于去重和展示 + 'payment_method_code' => $paymentMethodCode, + 'payment_channel' => $paymentChannel, + 'payment_status' => $paymentStatus, + 'consume_channel' => $consumeChannel, + 'consume_period' => $consumePeriod, + 'is_workday' => $this->isWorkday($consumeTime) ? 1 : 0, + 'status' => $status, + ]; + } + + /** + * 保存消费记录 + * + * @param array $recordData 记录数据 + * @return void + */ + private function saveConsumptionRecord(array $recordData): void + { + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤11-保存数据】开始保存消费记录\n"); + // 根据任务配置保存到目标数据源 + $targetDataSourceId = $this->taskConfig['target_data_source_id'] ?? null; + $targetDatabase = $this->taskConfig['target_database'] ?? null; + $targetCollection = $this->taskConfig['target_collection'] ?? 'consumption_records'; + + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤11-保存数据】目标配置: data_source_id={$targetDataSourceId}, database={$targetDatabase}, collection={$targetCollection}\n"); + + if (empty($targetDataSourceId)) { + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤11-保存数据】使用默认ConsumptionService(向后兼容)\n"); + // 如果没有配置目标数据源,使用默认的 ConsumptionService(向后兼容) + $result = $this->consumptionService->createRecord($recordData); + // 如果返回 null,说明手机号和身份证号都为空,跳过该记录 + if ($result === null) { + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤11-保存数据】⚠️ 跳过记录:手机号和身份证号都为空\n"); + return; + } + return; + } + + // 连接到目标数据源 + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤12-连接目标数据源】开始查询目标数据源配置: data_source_id={$targetDataSourceId}\n"); + $connectionInfo = $this->connectToTargetDataSource($targetDataSourceId, $targetDatabase); + $targetDataSourceConfig = $connectionInfo['config']; + $dbName = $connectionInfo['dbName']; + $database = $connectionInfo['database']; + + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤12-连接目标数据源】✓ 目标数据源配置查询成功: host={$targetDataSourceConfig['host']}, port={$targetDataSourceConfig['port']}\n"); + + // 根据消费时间确定月份集合(使用 Trait 方法) + $collectionName = $this->getMonthlyCollectionName( + $targetCollection, + $recordData['consume_time'] ?? null + ); + + // 解析用户ID(如果提供了手机号或身份证) + if (empty($recordData['user_id']) && (!empty($recordData['phone_number']) || !empty($recordData['id_card']))) { + // 解析 consume_time 作为查询时间点 + $consumeTime = null; + if (isset($recordData['consume_time'])) { + if (is_string($recordData['consume_time'])) { + $consumeTime = new \DateTimeImmutable($recordData['consume_time']); + } elseif ($recordData['consume_time'] instanceof \MongoDB\BSON\UTCDateTime) { + $timestamp = $recordData['consume_time']->toDateTime()->getTimestamp(); + $consumeTime = new \DateTimeImmutable('@' . $timestamp); + } + } + + $userId = $this->identifierService->resolvePersonId( + $recordData['phone_number'] ?? null, + $recordData['id_card'] ?? null, + $consumeTime + ); + $recordData['user_id'] = $userId; + } + + // 转换时间字段为 MongoDB UTCDateTime(在去重检查前转换,用于查询) + $consumeTimeForQuery = null; + if (isset($recordData['consume_time'])) { + if (is_string($recordData['consume_time'])) { + $consumeTimeForQuery = new \MongoDB\BSON\UTCDateTime(strtotime($recordData['consume_time']) * 1000); + } elseif ($recordData['consume_time'] instanceof \MongoDB\BSON\UTCDateTime) { + $consumeTimeForQuery = $recordData['consume_time']; + } + } + $recordData['consume_time'] = $consumeTimeForQuery ?? new \MongoDB\BSON\UTCDateTime(time() * 1000); + + if (empty($recordData['create_time'])) { + $recordData['create_time'] = new \MongoDB\BSON\UTCDateTime(time() * 1000); + } elseif (is_string($recordData['create_time'])) { + $recordData['create_time'] = new \MongoDB\BSON\UTCDateTime(strtotime($recordData['create_time']) * 1000); + } + + // 写入数据 + $collection = $database->selectCollection($collectionName); + + // 获取门店名称(用于去重) + // 优先使用从源数据映射的门店名称,无论店铺表查询结果如何 + $storeName = $recordData['store_name'] ?? null; + + // 如果源数据中没有 store_name 但有 store_id,尝试从店铺表获取门店名称(作为后备方案) + // 但即使查询失败,也要确保 store_name 字段被保存(可能为 null) + if (empty($storeName) && !empty($recordData['store_id'])) { + try { + $store = $this->storeService->getStoreById($recordData['store_id']); + if ($store && $store->store_name) { + $storeName = $store->store_name; + } + } catch (\Throwable $e) { + // 从店铺表反查失败不影响数据保存,只记录日志 + LoggerHelper::logError($e, [ + 'component' => 'ConsumptionCollectionHandler', + 'action' => 'saveConsumptionRecord', + 'message' => '从店铺表反查门店名称失败,将保存null值', + 'store_id' => $recordData['store_id'] ?? null, + ]); + } + } + + // 确保 store_name 字段被保存到 recordData 中(即使为 null 也要保存,保持数据结构一致) + $recordData['store_name'] = $storeName; + + // 基于业务唯一标识检查重复(防止重复插入) + // 方案:使用 store_name + source_order_id 作为唯一标识 + // 注意:order_no 是系统自动生成的(自动递增),不参与去重判断 + $duplicateQuery = null; + $duplicateIdentifier = null; + $sourceOrderId = $recordData['source_order_id'] ?? null; + + if (!empty($storeName) && !empty($sourceOrderId)) { + // 使用门店名称 + 原始订单ID作为唯一标识 + $duplicateQuery = [ + 'store_name' => $storeName, + 'source_order_id' => $sourceOrderId, + ]; + $duplicateIdentifier = "store_name={$storeName}, source_order_id={$sourceOrderId}"; + } + + // 如果找到了唯一标识,检查是否已存在 + if ($duplicateQuery) { + $existingRecord = $collection->findOne($duplicateQuery); + if ($existingRecord) { + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] ⚠️ 记录已存在,跳过插入: {$duplicateIdentifier}, collection={$collectionName}\n"); + LoggerHelper::logBusiness('consumption_record_duplicate_skipped', [ + 'duplicate_identifier' => $duplicateIdentifier, + 'target_collection' => $collectionName, + ]); + return; // 跳过重复记录 + } + } + + // 生成 record_id(如果还没有) + // 使用门店名称 + 原始订单ID生成稳定的 record_id + if (empty($recordData['record_id'])) { + if (!empty($storeName) && !empty($sourceOrderId)) { + // 使用门店名称 + 原始订单ID生成稳定的 record_id + $uniqueKey = "{$storeName}|{$sourceOrderId}"; + $recordData['record_id'] = 'store_source_' . md5($uniqueKey); + } else { + // 如果都没有,生成 UUID(这种情况应该很少) + $recordData['record_id'] = UuidGenerator::uuid4()->toString(); + } + } + + // 生成 order_no(系统自动生成,自动递增) + // 注意:order_no 不参与去重判断,仅用于展示和查询 + // 使用计数器集合来生成唯一的 order_no(在去重检查之后,只有实际插入的记录才生成 order_no) + if (empty($recordData['order_no'])) { + try { + // 使用计数器集合来生成唯一的 order_no + $counterCollection = $database->selectCollection($collectionName . '_counter'); + + // 原子性地递增计数器 + $counterResult = $counterCollection->findOneAndUpdate( + ['_id' => 'order_no'], + ['$inc' => ['seq' => 1], '$setOnInsert' => ['_id' => 'order_no', 'seq' => 1]], + ['upsert' => true, 'returnDocument' => 1] // 1 = RETURN_DOCUMENT_AFTER + ); + + $nextOrderNo = $counterResult['seq'] ?? 1; + $recordData['order_no'] = (string)$nextOrderNo; + } catch (\Throwable $e) { + // 如果计数器操作失败,回退到查询最大值的方案 + LoggerHelper::logError($e, [ + 'component' => 'ConsumptionCollectionHandler', + 'action' => 'saveConsumptionRecord', + 'message' => '使用计数器生成order_no失败,回退到查询最大值方案', + ]); + + try { + $maxOrderNo = $collection->findOne( + [], + ['sort' => ['order_no' => -1], 'projection' => ['order_no' => 1]] + ); + $nextOrderNo = 1; + if ($maxOrderNo && isset($maxOrderNo['order_no']) && is_numeric($maxOrderNo['order_no'])) { + $nextOrderNo = (int)$maxOrderNo['order_no'] + 1; + } + $recordData['order_no'] = (string)$nextOrderNo; + } catch (\Throwable $e2) { + // 如果查询也失败,使用时间戳作为备选方案 + $recordData['order_no'] = (string)(time() * 1000 + mt_rand(1000, 9999)); + LoggerHelper::logError($e2, [ + 'component' => 'ConsumptionCollectionHandler', + 'action' => 'saveConsumptionRecord', + 'message' => '查询最大order_no也失败,使用时间戳作为备选', + ]); + } + } + } + + // 格式化输出流水信息 + $timestamp = date('Y-m-d H:i:s'); + $recordId = $recordData['record_id'] ?? 'null'; + $userId = $recordData['user_id'] ?? 'null'; + $amount = $recordData['amount'] ?? 0; + $actualAmount = $recordData['actual_amount'] ?? $amount; + $consumeTime = isset($recordData['consume_time']) && $recordData['consume_time'] instanceof \MongoDB\BSON\UTCDateTime + ? $recordData['consume_time']->toDateTime()->format('Y-m-d H:i:s') + : ($recordData['consume_time'] ?? 'null'); + $storeId = $recordData['store_id'] ?? 'null'; + $phoneNumber = $recordData['phone_number'] ?? 'null'; + + // 确保 storeName 变量已定义(从 recordData 中获取,如果之前已赋值) + $storeNameOutput = $recordData['store_name'] ?? 'null'; + + // 输出详细的插入流水信息(输出到终端) + // $output = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + // . "📝 [{$timestamp}] 消费记录插入流水\n" + // . " ├─ 记录ID: {$recordId}\n" + // . " ├─ 用户ID: {$userId}\n" + // . " ├─ 手机号: {$phoneNumber}\n" + // . " ├─ 消费时间: {$consumeTime}\n" + // . " ├─ 消费金额: ¥" . number_format($amount, 2) . "\n" + // . " ├─ 实际金额: ¥" . number_format($actualAmount, 2) . "\n" + // . " ├─ 店铺ID: {$storeId}\n" + // . " ├─ 门店名称: {$storeNameOutput}\n" + // . " ├─ 目标数据库: {$dbName}\n" + // . " └─ 目标集合: {$collectionName}\n"; + + // // \Workerman\Worker::safeEcho($output); + + $result = $collection->insertOne($recordData); + $insertedId = $result->getInsertedId(); + + $successOutput = " ✅ 插入成功 | MongoDB ID: " . (is_object($insertedId) ? (string)$insertedId : json_encode($insertedId)) . "\n" + . "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"; + + // \Workerman\Worker::safeEcho($successOutput); + + LoggerHelper::logBusiness('consumption_record_saved_to_target', [ + 'target_data_source_id' => $targetDataSourceId, + 'target_database' => $dbName, + 'target_collection' => $collectionName, + 'record_id' => $recordData['record_id'], + 'inserted_id' => (string)$insertedId, + ]); + + // 更新用户统计信息(使用默认连接,因为用户数据在主数据库) + // 如果身份证和手机号都是空的(没有user_id),则不更新用户主表 + if (empty($recordData['user_id'])) { + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] ⚠️ 身份证和手机号都为空,跳过用户主表更新\n"); + LoggerHelper::logBusiness('consumption_record_skip_user_update_no_identifier', [ + 'record_id' => $recordData['record_id'] ?? null, + 'phone_number' => $recordData['phone_number'] ?? null, + 'id_card' => isset($recordData['id_card']) ? '***' : null, // 不记录敏感信息 + ]); + return; + } + + try { + $userProfileRepo = new \app\repository\UserProfileRepository(); + $consumeTime = isset($recordData['consume_time']) && $recordData['consume_time'] instanceof \MongoDB\BSON\UTCDateTime + ? \DateTimeImmutable::createFromMutable($recordData['consume_time']->toDateTime()) + : new \DateTimeImmutable(); + $user = $userProfileRepo->increaseStats( + $recordData['user_id'], + $recordData['actual_amount'] ?? $recordData['amount'] ?? 0, + $consumeTime + ); + } catch (\Exception $e) { + // 更新用户统计失败不影响数据保存,只记录日志 + LoggerHelper::logError($e, [ + 'component' => 'ConsumptionCollectionHandler', + 'action' => 'saveConsumptionRecord', + 'message' => '更新用户统计失败', + ]); + } + } + + /** + * 应用字段映射 + * + * @param array $sourceData 源数据 + * @param array $fieldMappings 字段映射配置 + * @return array 映射后的数据 + */ + private function applyFieldMappings(array $sourceData, array $fieldMappings): array + { + $mappedData = []; + + foreach ($fieldMappings as $mapping) { + $sourceField = $mapping['source_field'] ?? ''; + $targetField = $mapping['target_field'] ?? ''; + $transform = $mapping['transform'] ?? null; + + // 如果源字段或目标字段为空,跳过该映射 + if (empty($sourceField) || empty($targetField)) { + continue; + } + + // 从源数据中获取值(支持嵌套字段,如 "user.name") + $value = $this->getNestedValue($sourceData, $sourceField); + + // 调试:输出store_name字段的映射详情 + if ($targetField === 'store_name') { + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 【步骤9-字段映射】字段映射详情: target={$targetField}, source={$sourceField}, value=" . ($value ?? 'null') . "\n"); + } + + // 应用转换函数 + if ($transform && is_callable($transform)) { + $value = $transform($value); + } elseif ($transform && is_string($transform)) { + $value = $this->applyTransform($value, $transform); + } + + $mappedData[$targetField] = $value; + } + + return $mappedData; + } + + /** + * 获取嵌套字段值 + * + * @param array $data 数据 + * @param string $fieldPath 字段路径(支持嵌套,如 "user.name") + * @return mixed 字段值 + */ + private function getNestedValue(array $data, string $fieldPath) + { + $parts = explode('.', $fieldPath); + $value = $data; + + foreach ($parts as $part) { + if (is_array($value) && isset($value[$part])) { + $value = $value[$part]; + } elseif (is_object($value) && isset($value->$part)) { + $value = $value->$part; + } else { + return null; + } + } + + return $value; + } + + /** + * 应用转换函数 + * + * @param mixed $value 原始值 + * @param string $transform 转换函数名称 + * @return mixed 转换后的值 + */ + private function applyTransform($value, string $transform) + { + switch ($transform) { + case 'parse_amount': + return $this->parseAmount($value); + case 'parse_datetime': + return is_string($value) ? $value : ($value instanceof \DateTimeImmutable ? $value->format('Y-m-d H:i:s') : (string)$value); + case 'parse_phone': + return $this->extractPhoneNumberFromValue($value); + default: + return $value; + } + } + + /** + * 从值中提取手机号 + * + * @param mixed $value 值 + * @return string|null 手机号 + */ + private function extractPhoneNumberFromValue($value): ?string + { + if (empty($value)) { + return null; + } + $phone = trim((string)$value); + + // 先过滤非数字字符 + $cleanedPhone = $this->filterPhoneNumber($phone); + + // 验证过滤后的手机号 + if ($this->isValidPhone($cleanedPhone)) { + // 返回过滤后的手机号 + return $cleanedPhone; + } + + return null; + } + + /** + * 从订单数据中提取手机号 + * + * @param array $orderData 订单数据 + * @return string|null 手机号 + */ + private function extractPhoneNumber(array $orderData): ?string + { + // 优先使用支付宝账号(通常是手机号) + if (!empty($orderData['买家支付宝账号'])) { + $phone = trim($orderData['买家支付宝账号']); + // 先过滤非数字字符 + $cleanedPhone = $this->filterPhoneNumber($phone); + if (!empty($cleanedPhone) && $this->isValidPhone($cleanedPhone)) { + return $cleanedPhone; + } + } + + // 其次使用联系电话 + if (!empty($orderData['联系电话'])) { + $phone = trim($orderData['联系电话']); + // 先过滤非数字字符 + $cleanedPhone = $this->filterPhoneNumber($phone); + if (!empty($cleanedPhone) && $this->isValidPhone($cleanedPhone)) { + return $cleanedPhone; + } + } + + return null; + } + + /** + * 提取手机号(订单数据专用) + * + * 注意:这个方法保留在此类中,因为它处理的是订单数据的特定字段 + * 通用的手机号提取逻辑在 Trait 中 + */ + + + /** + * 解析支付方式 + * + * @param string $paymentDetail 支付详情 + * @return string 支付方式编码 + */ + private function parsePaymentMethod(string $paymentDetail): string + { + $detail = strtolower($paymentDetail); + + if (strpos($detail, '支付宝') !== false || strpos($detail, 'alipay') !== false) { + return 'alipay'; + } + if (strpos($detail, '微信') !== false || strpos($detail, 'wechat') !== false || strpos($detail, 'weixin') !== false) { + return 'wechat'; + } + if (strpos($detail, '银行卡') !== false || strpos($detail, 'card') !== false) { + return 'bank_card'; + } + + return 'other'; + } + + /** + * 解析支付状态 + * + * @param string $orderStatus 订单状态 + * @return int 支付状态:0-成功,1-失败,2-退款 + */ + private function parsePaymentStatus(string $orderStatus): int + { + $status = strtolower($orderStatus); + + if (strpos($status, '退款') !== false || strpos($status, 'refund') !== false) { + return 2; // 退款 + } + if (strpos($status, '失败') !== false || strpos($status, 'fail') !== false) { + return 1; // 失败 + } + + return 0; // 成功 + } + + /** + * 解析消费渠道 + * + * @param string $isMobileOrder 是否手机订单 + * @return string 消费渠道 + */ + private function parseConsumeChannel(string $isMobileOrder): string + { + if (strpos($isMobileOrder, '是') !== false || strpos(strtolower($isMobileOrder), 'true') !== false || $isMobileOrder === '1') { + return '线上_移动端'; + } + return '线上_PC端'; + } + + /** + * 解析消费时段 + * + * @param \DateTimeImmutable $dateTime 日期时间 + * @return string 消费时段 + */ + private function parseConsumePeriod(\DateTimeImmutable $dateTime): string + { + $hour = (int)$dateTime->format('H'); + + if ($hour >= 6 && $hour < 12) { + return '上午'; + } elseif ($hour >= 12 && $hour < 14) { + return '中午'; + } elseif ($hour >= 14 && $hour < 18) { + return '下午'; + } elseif ($hour >= 18 && $hour < 22) { + return '晚上'; + } else { + return '深夜'; + } + } + + /** + * 判断是否为工作日 + * + * @param \DateTimeImmutable $dateTime 日期时间 + * @return bool 是否为工作日 + */ + private function isWorkday(\DateTimeImmutable $dateTime): bool + { + $dayOfWeek = (int)$dateTime->format('w'); // 0=Sunday, 6=Saturday + return $dayOfWeek >= 1 && $dayOfWeek <= 5; + } + + + /** + * 从集合名称中提取用户等级 + * + * @param string $collectionName 集合名称 + * @return string 用户等级 + */ + private function extractUserLevel(string $collectionName): string + { + if (preg_match('/[ABCEDS]级用户/', $collectionName, $matches)) { + return $matches[0]; + } + return '未知'; + } + + + /** + * 实时监听KR商城集合变化 + * + * @param array $taskConfig 任务配置 + * @return void + */ + private function watchKrMallCollection(array $taskConfig): void + { + $databaseName = $taskConfig['database'] ?? 'KR_商城'; + $collectionName = $taskConfig['collection'] ?? '21年贝蒂喜订单整合'; + + LoggerHelper::logBusiness('kr_mall_realtime_watch_start', [ + 'database' => $databaseName, + 'collection' => $collectionName, + ]); + + $client = $this->getMongoClient($taskConfig); + $database = $client->selectDatabase($databaseName); + $collection = $database->selectCollection($collectionName); + + // 创建Change Stream监听集合变化 + $changeStream = $collection->watch( + [], + [ + 'fullDocument' => 'updateLookup', + 'batchSize' => 100, + 'maxAwaitTimeMS' => 1000, + ] + ); + + LoggerHelper::logBusiness('kr_mall_realtime_watch_ready', [ + 'database' => $databaseName, + 'collection' => $collectionName, + ]); + + // 处理变更事件 + foreach ($changeStream as $change) { + try { + $operationType = $change['operationType'] ?? ''; + + // 只处理插入和更新操作 + if ($operationType === 'insert' || $operationType === 'update') { + $document = $change['fullDocument'] ?? null; + + if ($document === null) { + // 如果是更新操作但没有fullDocument,需要查询完整文档 + if ($operationType === 'update') { + $documentId = $change['documentKey']['_id'] ?? null; + if ($documentId !== null) { + $document = $collection->findOne(['_id' => $documentId]); + } + } + } + + if ($document !== null) { + $orderData = $this->convertMongoDocumentToArray($document); + $orderNo = $orderData['订单编号'] ?? 'unknown'; + + try { + // \Workerman\Worker::safeEcho(" 🔔 [实时监听] 检测到变更: operation={$operationType}, 订单编号={$orderNo}\n"); + $this->processKrMallOrder($orderData, $taskConfig); + + LoggerHelper::logBusiness('kr_mall_realtime_record_processed', [ + 'operation' => $operationType, + 'order_no' => $orderNo, + ]); + } catch (\Exception $e) { + $errorMsg = $e->getMessage(); + // \Workerman\Worker::safeEcho(" ❌ [实时监听] 处理失败: 订单编号={$orderNo}, 错误={$errorMsg}\n"); + LoggerHelper::logError($e, [ + 'component' => 'ConsumptionCollectionHandler', + 'action' => 'processKrMallOrder_realtime', + 'operation' => $operationType, + 'order_no' => $orderNo, + ]); + } + } + } + } catch (\Exception $e) { + LoggerHelper::logError($e, [ + 'component' => 'ConsumptionCollectionHandler', + 'action' => 'watchKrMallCollection', + 'change' => $change, + ]); + } + } + } + + /** + * 实时监听KR金融集合变化 + * + * @param array $taskConfig 任务配置 + * @return void + */ + private function watchKrFinanceCollections(array $taskConfig): void + { + $databaseName = $taskConfig['database'] ?? 'KR'; + $collections = $taskConfig['collections'] ?? [ + '金融客户_厦门_A级用户', + '金融客户_厦门_B级用户', + '金融客户_厦门_C级用户', + '金融客户_厦门_D级用户', + '金融客户_厦门_E级用户', + '厦门用户资产2025年9月_优化版', + ]; + + LoggerHelper::logBusiness('kr_finance_realtime_watch_start', [ + 'database' => $databaseName, + 'collections' => $collections, + ]); + + $client = $this->getMongoClient($taskConfig); + $database = $client->selectDatabase($databaseName); + + // 使用数据库级别的Change Stream监听所有集合 + $changeStream = $database->watch( + [], + [ + 'fullDocument' => 'updateLookup', + 'batchSize' => 100, + 'maxAwaitTimeMS' => 1000, + ] + ); + + LoggerHelper::logBusiness('kr_finance_realtime_watch_ready', [ + 'database' => $databaseName, + ]); + + // 处理变更事件 + foreach ($changeStream as $change) { + try { + $collectionName = $change['ns']['coll'] ?? ''; + + // 只处理配置的集合 + if (!in_array($collectionName, $collections)) { + continue; + } + + $operationType = $change['operationType'] ?? ''; + + // 只处理插入和更新操作,且必须有loan_amount字段 + if ($operationType === 'insert' || $operationType === 'update') { + $document = $change['fullDocument'] ?? null; + + if ($document === null && $operationType === 'update') { + // 如果是更新操作但没有fullDocument,需要查询完整文档 + $documentId = $change['documentKey']['_id'] ?? null; + if ($documentId !== null) { + $collection = $database->selectCollection($collectionName); + $document = $collection->findOne(['_id' => $documentId]); + } + } + + if ($document !== null) { + $docArray = $this->convertMongoDocumentToArray($document); + + // 检查是否有loan_amount字段 + if (isset($docArray['loan_amount']) && !empty($docArray['loan_amount'])) { + try { + $this->processKrFinanceRecord($docArray, $collectionName, $taskConfig); + + LoggerHelper::logBusiness('kr_finance_realtime_record_processed', [ + 'operation' => $operationType, + 'collection' => $collectionName, + 'mobile' => $docArray['mobile'] ?? 'unknown', + ]); + } catch (\Exception $e) { + LoggerHelper::logError($e, [ + 'component' => 'ConsumptionCollectionHandler', + 'action' => 'processKrFinanceRecord_realtime', + 'operation' => $operationType, + 'collection' => $collectionName, + ]); + } + } + } + } + } catch (\Exception $e) { + LoggerHelper::logError($e, [ + 'component' => 'ConsumptionCollectionHandler', + 'action' => 'watchKrFinanceCollections', + 'change' => $change, + ]); + } + } + } + + /** + * 检查任务状态(是否应该继续执行) + * + * @param string $taskId 任务ID + * @return bool true=继续执行, false=暂停/停止 + */ + private function checkTaskStatus(string $taskId): bool + { + // 检查Redis标志 + if (\app\utils\RedisHelper::exists("data_collection_task:{$taskId}:pause")) { + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 检测到暂停标志,任务 {$taskId} 暂停\n"); + return false; + } + if (\app\utils\RedisHelper::exists("data_collection_task:{$taskId}:stop")) { + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 检测到停止标志,任务 {$taskId} 停止\n"); + return false; + } + + // 检查数据库状态 + $task = $this->taskService->getTask($taskId); + if ($task && in_array($task['status'], ['paused', 'stopped', 'error'])) { + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 检测到任务状态为 {$task['status']},任务 {$taskId} 停止\n"); + return false; + } + + return true; + } + + /** + * 根据总记录数计算合适的进度更新间隔 + * + * @param int $totalCount 总记录数 + * @return int 更新间隔(每处理多少条记录更新一次) + */ + private function calculateProgressUpdateInterval(int $totalCount): int + { + // 根据总数动态调整更新间隔,确保既不会太频繁也不会太慢 + // 策略:大约每1%更新一次,但限制在合理范围内 + + if ($totalCount <= 0) { + return 50; // 默认50条 + } + + // 计算1%的数量 + $onePercent = max(1, (int)($totalCount * 0.01)); + + // 根据总数范围调整: + // - 小于1000条:每50条更新(保证至少更新20次) + // - 1000-10000条:每1%更新(约10-100条) + // - 10000-100000条:每1%更新(约100-1000条) + // - 100000-1000000条:每1%更新(约1000-10000条),但最多5000条 + // - 大于1000000条:每5000条更新(避免更新太频繁) + + if ($totalCount < 1000) { + return 50; + } elseif ($totalCount < 10000) { + return max(50, min(500, $onePercent)); + } elseif ($totalCount < 100000) { + return max(100, min(1000, $onePercent)); + } elseif ($totalCount < 1000000) { + return max(500, min(5000, $onePercent)); + } else { + return 5000; // 大数据量固定5000条更新一次 + } + } + + /** + * 更新任务进度 + * + * @param string $taskId 任务ID + * @param array $progress 进度信息(可以包含status字段来更新任务状态) + * @return void + */ + private function updateProgress(string $taskId, array $progress): void + { + try { + $task = $this->taskService->getTask($taskId); + if (!$task) { + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] ⚠️ 更新进度失败:任务不存在 task_id={$taskId}\n"); + return; + } + + $currentProgress = $task['progress'] ?? []; + + // 保护已完成任务的进度:如果任务已完成且百分比为100%,且没有明确指定要更新百分比,则保护当前进度 + $isCompleted = $task['status'] === 'completed'; + $currentPercentage = $currentProgress['percentage'] ?? 0; + $shouldProtectProgress = $isCompleted && $currentPercentage === 100 && !isset($progress['percentage']); + + // 检查是否需要更新任务状态 + $updateTaskStatus = false; + $newStatus = null; + if (isset($progress['status'])) { + $newStatus = $progress['status']; + unset($progress['status']); // 从progress中移除,单独处理 + + // 如果当前任务状态是 completed,不允许再更新为 running(防止循环) + // 只有用户手动重新启动任务时(通过 startTask),才会从 completed 变为 running + if ($newStatus === 'running' && $task['status'] === 'completed') { + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] ⚠️ 任务已完成,不允许更新为 running,跳过状态更新\n"); + LoggerHelper::logBusiness('task_status_update_skipped_completed', [ + 'task_id' => $taskId, + 'current_status' => $task['status'], + 'attempted_status' => $newStatus, + ]); + } else { + $updateTaskStatus = true; + } + } + + // 合并进度信息 + foreach ($progress as $key => $value) { + $currentProgress[$key] = $value; + } + + // 确保 percentage 字段存在且正确计算(基于已采集条数/总条数) + if (isset($currentProgress['processed_count']) && isset($currentProgress['total_count'])) { + if ($currentProgress['total_count'] > 0) { + // 进度 = 已采集条数 / 总条数 * 100 + $calculatedPercentage = round( + ($currentProgress['processed_count'] / $currentProgress['total_count']) * 100, + 2 + ); + // 确保不超过100% + $calculatedPercentage = min(100, $calculatedPercentage); + + // 如果任务已完成且当前百分比为100%,且计算出的百分比小于100%,保持100% + // 否则使用计算出的百分比 + if ($isCompleted && $currentPercentage === 100 && $calculatedPercentage < 100) { + $currentProgress['percentage'] = 100; // 保护已完成任务的100%进度 + } else { + $currentProgress['percentage'] = $calculatedPercentage; + } + } else { + // 如果 total_count 为 0,但任务已完成且百分比为100%,保持100% + // 否则设置为0(表示重新开始) + if ($isCompleted && $currentPercentage === 100) { + $currentProgress['percentage'] = 100; // 保持100% + } else { + $currentProgress['percentage'] = 0; + } + } + } elseif ($shouldProtectProgress) { + // 如果没有传入 processed_count 或 total_count,但应该保护进度,保持当前百分比 + $currentProgress['percentage'] = $currentPercentage; + } elseif (!isset($currentProgress['percentage'])) { + // 如果没有传入 percentage,且不需要保护,保持当前百分比(如果存在) + $currentProgress['percentage'] = $currentPercentage; + } + + // 输出进度更新日志(用于调试) + $processedCount = $currentProgress['processed_count'] ?? 0; + $totalCount = $currentProgress['total_count'] ?? 0; + $percentage = $currentProgress['percentage'] ?? 0; + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] 📊 更新进度: processed={$processedCount}/{$totalCount}, percentage={$percentage}%\n"); + + // 更新进度到数据库 + $result = $this->taskService->updateProgress($taskId, $currentProgress); + if ($result) { + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] ✅ 进度已保存到数据库\n"); + } else { + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] ⚠️ 进度保存到数据库失败\n"); + } + + // 如果指定了状态,更新任务状态(例如:completed) + if ($updateTaskStatus && $newStatus !== null) { + $this->taskService->updateTask($taskId, ['status' => $newStatus]); + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] ✅ 任务状态已更新为: {$newStatus}\n"); + } + } catch (\Exception $e) { + // \Workerman\Worker::safeEcho("[ConsumptionCollectionHandler] ❌ 更新进度异常: " . $e->getMessage() . "\n"); + LoggerHelper::logError($e, [ + 'component' => 'ConsumptionCollectionHandler', + 'action' => 'updateProgress', + 'task_id' => $taskId, + ]); + } + } +} + diff --git a/app/service/DataCollection/Handler/DatabaseSyncHandler.php b/app/service/DataCollection/Handler/DatabaseSyncHandler.php new file mode 100644 index 0000000..5df5943 --- /dev/null +++ b/app/service/DataCollection/Handler/DatabaseSyncHandler.php @@ -0,0 +1,427 @@ + $taskConfig 任务配置 + * @return void + */ + public function collect(DataSourceAdapterInterface $adapter, array $taskConfig): void + { + $this->taskConfig = $taskConfig; + $taskId = $taskConfig['task_id'] ?? ''; + $taskName = $taskConfig['name'] ?? '数据库同步'; + + LoggerHelper::logBusiness('database_sync_collection_started', [ + 'task_id' => $taskId, + 'task_name' => $taskName, + ]); + // 控制台直接输出一条提示,方便在启动时观察数据库同步任务是否真正开始执行 + error_log("[DatabaseSyncHandler] 数据库同步任务已启动:task_id={$taskId}, task_name={$taskName}"); + + try { + // 创建 DatabaseSyncService(使用任务配置中的源和目标数据源) + $this->syncService = $this->createSyncService($taskConfig); + + // 获取要同步的数据库列表 + $databases = $this->getDatabasesToSync($taskConfig); + + if (empty($databases)) { + LoggerHelper::logBusiness('database_sync_no_databases', [ + 'task_id' => $taskId, + 'message' => '没有找到要同步的数据库', + ]); + return; + } + + // 启动进度日志定时器(定期输出同步进度) + $this->startProgressTimer($taskConfig); + + // 是否执行全量同步(从业务配置中获取) + $businessConfig = $this->getBusinessConfig(); + $fullSyncEnabled = $businessConfig['change_stream']['full_sync_on_start'] ?? false; + + if ($fullSyncEnabled) { + // 执行全量同步 + $this->performFullSync($databases, $taskConfig); + } + + // 启动增量同步监听(Change Streams) + $this->startIncrementalSync($databases, $taskConfig); + + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'DatabaseSyncHandler', + 'action' => 'collect', + 'task_id' => $taskId, + ]); + throw $e; + } + } + + /** + * 获取业务配置(从独立配置文件或使用默认值) + * + * @return array 业务配置 + */ + private function getBusinessConfig(): array + { + // 可以从独立配置文件读取,或使用默认值 + // 这里使用默认值,业务逻辑统一在代码中管理 + return [ + // 数据库同步配置 + 'databases' => [], // 空数组表示同步所有数据库 + 'exclude_databases' => ['admin', 'local', 'config'], // 排除的系统数据库 + 'exclude_collections' => ['system.profile', 'system.js'], // 排除的系统集合 + + // Change Streams 配置 + 'change_stream' => [ + 'batch_size' => 100, + 'max_await_time_ms' => 1000, + 'full_sync_on_start' => true, // 首次启动时是否执行全量同步 + 'full_sync_batch_size' => 1000, + ], + + // 重试配置 + 'retry' => [ + 'max_connect_retries' => 10, + 'retry_interval' => 5, + 'max_sync_retries' => 3, + 'sync_retry_interval' => 2, + ], + + // 性能配置 + 'performance' => [ + 'concurrent_databases' => 5, + 'concurrent_collections' => 10, + 'batch_write_size' => 5000, + // 为了让断点续传逻辑简单可靠,这里关闭集合级并行同步 + // 后续如果需要再做更复杂的分片断点策略,可以重新打开 + 'enable_parallel_sync' => false, + 'max_parallel_tasks_per_collection' => 4, + 'documents_per_task' => 100000, + ], + + // 监控配置 + 'monitoring' => [ + 'log_sync' => true, + 'log_detail' => false, + 'stats_interval' => 10, // 每10秒输出一次进度日志 + ], + ]; + } + + /** + * 创建 DatabaseSyncService 实例 + * + * @param array $taskConfig 任务配置 + * @return DatabaseSyncService + */ + private function createSyncService(array $taskConfig): DatabaseSyncService + { + // 从数据库获取源和目标数据源配置 + $dataSourceService = new DataSourceService(new DataSourceRepository()); + $sourceDataSourceId = $taskConfig['source_data_source'] ?? 'kr_mongodb'; + $targetDataSourceId = $taskConfig['target_data_source'] ?? 'sync_mongodb'; + + $sourceConfig = $dataSourceService->getDataSourceConfigById($sourceDataSourceId); + $targetConfig = $dataSourceService->getDataSourceConfigById($targetDataSourceId); + + if (empty($sourceConfig) || empty($targetConfig)) { + throw new \InvalidArgumentException("数据源配置不存在: source={$sourceDataSourceId}, target={$targetDataSourceId}"); + } + + // 获取业务配置(统一在代码中管理) + $businessConfig = $this->getBusinessConfig(); + + // 构建同步配置 + $syncConfig = [ + 'enabled' => true, + 'source' => [ + 'host' => $sourceConfig['host'], + 'port' => $sourceConfig['port'], + 'username' => $sourceConfig['username'] ?? '', + 'password' => $sourceConfig['password'] ?? '', + 'auth_source' => $sourceConfig['auth_source'] ?? 'admin', + 'options' => array_merge([ + 'connectTimeoutMS' => 10000, + 'socketTimeoutMS' => 30000, + 'serverSelectionTimeoutMS' => 10000, + 'heartbeatFrequencyMS' => 10000, + ], $sourceConfig['options'] ?? []), + ], + 'target' => [ + 'host' => $targetConfig['host'], + 'port' => $targetConfig['port'], + 'username' => $targetConfig['username'] ?? '', + 'password' => $targetConfig['password'] ?? '', + 'auth_source' => $targetConfig['auth_source'] ?? 'admin', + 'options' => array_merge([ + 'connectTimeoutMS' => 10000, + 'socketTimeoutMS' => 30000, + 'serverSelectionTimeoutMS' => 10000, + ], $targetConfig['options'] ?? []), + ], + 'sync' => [ + 'databases' => $businessConfig['databases'], + 'exclude_databases' => $businessConfig['exclude_databases'], + 'exclude_collections' => $businessConfig['exclude_collections'], + 'change_stream' => $businessConfig['change_stream'], + 'retry' => $businessConfig['retry'], + 'performance' => $businessConfig['performance'], + ], + 'monitoring' => $businessConfig['monitoring'], + ]; + + // 直接传递配置给 DatabaseSyncService 构造函数 + return new DatabaseSyncService($syncConfig); + } + + /** + * 获取要同步的数据库列表 + * + * @param array $taskConfig 任务配置 + * @return array 数据库名称列表 + */ + private function getDatabasesToSync(array $taskConfig): array + { + return $this->syncService->getDatabasesToSync(); + } + + /** + * 执行全量同步(支持多进程数据库级并行) + * + * @param array $databases 数据库列表 + * @param array $taskConfig 任务配置 + * @return void + */ + private function performFullSync(array $databases, array $taskConfig): void + { + // 获取 Worker 信息(用于多进程分配) + $workerId = $taskConfig['worker_id'] ?? 0; + $workerCount = $taskConfig['worker_count'] ?? 1; + + // 分配数据库给当前 Worker(负载均衡算法) + $assignedDatabases = $this->assignDatabasesToWorker($databases, $workerId, $workerCount); + + LoggerHelper::logBusiness('database_sync_full_sync_start', [ + 'worker_id' => $workerId, + 'worker_count' => $workerCount, + 'total_databases' => count($databases), + 'assigned_databases' => $assignedDatabases, + 'assigned_count' => count($assignedDatabases), + ]); + + foreach ($assignedDatabases as $databaseName) { + try { + $this->syncService->fullSyncDatabase($databaseName); + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'DatabaseSyncHandler', + 'action' => 'performFullSync', + 'database' => $databaseName, + 'worker_id' => $workerId, + ]); + // 继续同步其他数据库 + } + } + + LoggerHelper::logBusiness('database_sync_full_sync_completed', [ + 'worker_id' => $workerId, + 'databases' => $assignedDatabases, + ]); + } + + /** + * 分配数据库给当前 Worker(负载均衡算法) + * + * 策略: + * 1. 按数据库大小排序(小库优先,提升完成感) + * 2. 使用贪心算法:每次分配给当前负载最小的 Worker + * 3. 考虑 Worker 当前处理的数据库数量 + * + * @param array $databases 数据库列表(已按大小排序) + * @param int $workerId 当前 Worker ID + * @param int $workerCount Worker 总数 + * @return array 分配给当前 Worker 的数据库列表 + */ + private function assignDatabasesToWorker(array $databases, int $workerId, int $workerCount): array + { + // 如果只有一个 Worker,返回所有数据库 + if ($workerCount <= 1) { + return $databases; + } + + // 方案A:简单取模分配(快速实现) + // 适用于数据库数量较多且大小相近的场景 + $assignedDatabases = []; + foreach ($databases as $index => $databaseName) { + if ($index % $workerCount === $workerId) { + $assignedDatabases[] = $databaseName; + } + } + + // 方案B:负载均衡分配(推荐,但需要数据库大小信息) + // 由于 getDatabasesToSync 已经按大小排序,简单取模即可实现较好的负载均衡 + // 如果后续需要更精确的负载均衡,可以从 DatabaseSyncService 获取数据库大小信息 + + return $assignedDatabases; + } + + /** + * 启动增量同步监听(支持多进程数据库级并行) + * + * @param array $databases 数据库列表 + * @param array $taskConfig 任务配置 + * @return void + */ + private function startIncrementalSync(array $databases, array $taskConfig): void + { + // 获取 Worker 信息(用于多进程分配) + $workerId = $taskConfig['worker_id'] ?? 0; + $workerCount = $taskConfig['worker_count'] ?? 1; + + // 分配数据库给当前 Worker(与全量同步使用相同的分配策略) + $assignedDatabases = $this->assignDatabasesToWorker($databases, $workerId, $workerCount); + + LoggerHelper::logBusiness('database_sync_incremental_sync_start', [ + 'worker_id' => $workerId, + 'worker_count' => $workerCount, + 'total_databases' => count($databases), + 'assigned_databases' => $assignedDatabases, + 'assigned_count' => count($assignedDatabases), + ]); + + // 为分配给当前 Worker 的数据库启动监听(在后台进程中) + foreach ($assignedDatabases as $databaseName) { + // 使用 Timer 在后台启动监听,避免阻塞 + \Workerman\Timer::add(0, function () use ($databaseName) { + try { + $this->syncService->watchDatabase($databaseName); + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'DatabaseSyncHandler', + 'action' => 'startIncrementalSync', + 'database' => $databaseName, + ]); + + // 重试逻辑(从业务配置中获取) + $businessConfig = $this->getBusinessConfig(); + $retryConfig = $businessConfig['retry'] ?? []; + $maxRetries = $retryConfig['max_connect_retries'] ?? 10; + $retryInterval = $retryConfig['retry_interval'] ?? 5; + + static $retryCount = []; + if (!isset($retryCount[$databaseName])) { + $retryCount[$databaseName] = 0; + } + + if ($retryCount[$databaseName] < $maxRetries) { + $retryCount[$databaseName]++; + \Workerman\Timer::add($retryInterval, function () use ($databaseName) { + $this->startIncrementalSync([$databaseName], $this->taskConfig); + }, [], false); + } + } + }, [], false); + } + } + + /** + * 启动进度日志定时器 + * + * @param array $taskConfig 任务配置 + * @return void + */ + private function startProgressTimer(array $taskConfig): void + { + $businessConfig = $this->getBusinessConfig(); + $statsInterval = $businessConfig['monitoring']['stats_interval'] ?? 10; // 默认10秒输出一次进度 + + // 使用 Workerman Timer 定期输出进度 + $this->progressTimerId = \Workerman\Timer::add($statsInterval, function () use ($taskConfig) { + try { + // 重新加载最新进度(从文件读取) + $this->syncService->loadProgress(); + $progress = $this->syncService->getProgress(); + $stats = $this->syncService->getStats(); + + // 输出格式化的进度信息 + $progressInfo = [ + 'task_id' => $taskConfig['task_id'] ?? '', + 'task_name' => $taskConfig['name'] ?? '数据库同步', + 'status' => $progress['status'], + 'progress_percent' => $progress['progress_percent'] . '%', + 'current_database' => $progress['current_database'] ?? '无', + 'current_collection' => $progress['current_collection'] ?? '无', + 'databases' => "{$progress['databases']['completed']}/{$progress['databases']['total']}", + 'collections' => "{$progress['collections']['completed']}/{$progress['collections']['total']}", + 'documents' => "{$progress['documents']['processed']}/{$progress['documents']['total']}", + 'documents_inserted' => $stats['documents_inserted'], + 'documents_updated' => $stats['documents_updated'], + 'documents_deleted' => $stats['documents_deleted'], + 'errors' => $stats['errors'], + 'elapsed_time' => round($progress['time']['elapsed_seconds'], 2) . 's', + 'estimated_remaining' => $progress['time']['estimated_remaining_seconds'] + ? round($progress['time']['estimated_remaining_seconds'], 2) . 's' + : '计算中...', + ]; + + // 输出到日志 + LoggerHelper::logBusiness('database_sync_progress_report', $progressInfo); + + // 如果状态是错误,输出错误信息 + if ($progress['status'] === 'error' && isset($progress['last_error'])) { + LoggerHelper::logBusiness('database_sync_error_info', [ + 'error_message' => $progress['last_error']['message'] ?? '未知错误', + 'error_database' => $progress['error_database'] ?? '未知', + 'error_collection' => $progress['last_error']['collection'] ?? '未知', + ]); + } + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'DatabaseSyncHandler', + 'action' => 'startProgressTimer', + ]); + } + }); + } + + /** + * 停止进度日志定时器 + * + * @return void + */ + public function stopProgressTimer(): void + { + if ($this->progressTimerId > 0) { + \Workerman\Timer::del($this->progressTimerId); + $this->progressTimerId = 0; + } + } +} + diff --git a/app/service/DataCollection/Handler/GenericCollectionHandler.php b/app/service/DataCollection/Handler/GenericCollectionHandler.php new file mode 100644 index 0000000..0279191 --- /dev/null +++ b/app/service/DataCollection/Handler/GenericCollectionHandler.php @@ -0,0 +1,1360 @@ +taskService = new DataCollectionTaskService( + new \app\repository\DataCollectionTaskRepository() + ); + } + + /** + * 采集数据 + * + * @param \app\service\DataSource\DataSourceAdapterInterface $adapter 数据源适配器 + * @param array $taskConfig 任务配置 + * @return void + */ + public function collect($adapter, array $taskConfig): void + { + $this->taskConfig = $taskConfig; + $taskId = $taskConfig['task_id'] ?? ''; + $taskName = $taskConfig['name'] ?? '通用采集任务'; + $mode = $taskConfig['mode'] ?? 'batch'; // batch: 批量采集, realtime: 实时监听 + + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤5-Handler开始】任务ID={$taskId}, 任务名称={$taskName}, 模式={$mode}\n"); + LoggerHelper::logBusiness('generic_collection_started', [ + 'task_id' => $taskId, + 'task_name' => $taskName, + 'mode' => $mode, + ]); + + try { + // 检查任务状态(从Redis或数据库) + if (!$this->checkTaskStatus($taskId)) { + LoggerHelper::logBusiness('generic_collection_skipped', [ + 'task_id' => $taskId, + 'reason' => '任务已暂停或停止', + ]); + return; + } + + // 根据模式执行不同的采集逻辑 + if ($mode === 'realtime') { + $this->watchCollection($taskConfig); + } else { + $this->collectBatch($adapter, $taskConfig); + } + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'GenericCollectionHandler', + 'action' => 'collect', + 'task_id' => $taskId, + ]); + + // 更新任务状态为错误 + $this->taskService->updateTask($taskId, [ + 'status' => 'error', + 'progress.status' => 'error', + 'progress.last_error' => $e->getMessage(), + ]); + + throw $e; + } + } + + /** + * 批量采集 + */ + private function collectBatch($adapter, array $taskConfig): void + { + $taskId = $taskConfig['task_id'] ?? ''; + $database = $taskConfig['database'] ?? ''; + $collection = $taskConfig['collection'] ?? null; + $collections = $taskConfig['collections'] ?? null; + $fieldMappings = $taskConfig['field_mappings'] ?? []; + $filterConditions = $taskConfig['filter_conditions'] ?? []; + $batchSize = $taskConfig['batch_size'] ?? 1000; + + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤6-连接源数据库】开始连接源数据库: database={$database}\n"); + $client = $this->getMongoClient($taskConfig); + $db = $client->selectDatabase($database); + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤6-连接源数据库】✓ 源数据库连接成功: database={$database}\n"); + + // 确定要处理的集合列表 + $targetCollections = []; + if ($collection) { + $targetCollections[] = $collection; + } elseif ($collections && is_array($collections)) { + $targetCollections = $collections; + } else { + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤6-连接源数据库】✗ 未指定collection或collections\n"); + throw new \InvalidArgumentException('必须指定 collection 或 collections'); + } + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤6-连接源数据库】要处理的集合: " . json_encode($targetCollections, JSON_UNESCAPED_UNICODE) . "\n"); + + // 先计算总记录数(用于进度计算和更新间隔) + $totalCount = 0; + $filter = $this->buildFilter($filterConditions); + foreach ($targetCollections as $collName) { + $coll = $db->selectCollection($collName); + $collTotal = $coll->countDocuments($filter); + $totalCount += $collTotal; + } + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤6-连接源数据库】总记录数: {$totalCount}\n"); + + // 计算进度更新间隔(根据总数动态调整) + $updateInterval = $this->calculateProgressUpdateInterval($totalCount); + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤6-连接源数据库】进度更新间隔: 每 {$updateInterval} 条更新一次\n"); + + // 更新进度:开始 + $this->updateProgress($taskId, [ + 'status' => 'running', + 'start_time' => new \MongoDB\BSON\UTCDateTime(time() * 1000), + 'total_count' => $totalCount, + 'processed_count' => 0, + 'success_count' => 0, + 'error_count' => 0, + 'percentage' => 0, + ]); + + $processedCount = 0; + $successCount = 0; + $errorCount = 0; + $lastUpdateCount = 0; // 记录上次更新的处理数量 + + foreach ($targetCollections as $collName) { + if (!$this->checkTaskStatus($taskId)) { + break; // 任务已暂停或停止 + } + + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤7-处理集合】开始处理集合: collection={$collName}\n"); + $coll = $db->selectCollection($collName); + + // 获取该集合的字段映射(优先使用集合级映射) + $collectionFieldMappings = $this->getFieldMappingsForCollection($collName, $taskConfig); + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤7-处理集合】字段映射数量: " . count($collectionFieldMappings) . "\n"); + + // 获取该集合的连表查询配置 + $collectionLookups = $this->getLookupsForCollection($collName, $taskConfig); + + // 如果有连表查询,使用聚合管道 + if (!empty($collectionLookups)) { + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤7-处理集合】使用连表查询模式\n"); + $result = $this->collectWithLookup($coll, $collName, $collectionFieldMappings, $collectionLookups, $filterConditions, $taskConfig, $taskId); + $processedCount += $result['processed']; + $successCount += $result['success']; + $errorCount += $result['error']; + } else { + // 普通查询 + // 构建查询条件 + $filter = $this->buildFilter($filterConditions); + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤7-处理集合】查询条件: " . json_encode($filter, JSON_UNESCAPED_UNICODE) . "\n"); + + // 获取当前集合的总数(用于日志) + $collTotalCount = $coll->countDocuments($filter); + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤7-处理集合】集合总文档数: {$collTotalCount}\n"); + + // 分页查询 + $offset = 0; + do { + if (!$this->checkTaskStatus($taskId)) { + break; // 任务已暂停或停止 + } + + $cursor = $coll->find( + $filter, + [ + 'limit' => $batchSize, + 'skip' => $offset, + ] + ); + + $batch = []; + foreach ($cursor as $doc) { + $batch[] = $this->convertMongoDocumentToArray($doc); + } + + if (empty($batch)) { + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤8-查询数据】批次为空,结束查询\n"); + break; + } + + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤8-查询数据】查询到 {$batchSize} 条数据,offset={$offset}\n"); + + // 处理批量数据 + foreach ($batch as $index => $docData) { + // 检查是否已达到总数(在每条处理前检查,避免超出) + if ($totalCount > 0 && $processedCount >= $totalCount) { + break 2; // 跳出两层循环(foreach 和 do-while) + } + + $processedCount++; + try { + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤9-处理文档】开始处理第 {$processedCount} 条文档 (批次内第 " . ($index + 1) . " 条)\n"); + $this->processDocument($docData, $collectionFieldMappings, $taskConfig); + $successCount++; + if (($index + 1) % 100 === 0) { + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤9-处理文档】已处理 {$successCount} 条成功\n"); + } + } catch (\Exception $e) { + $errorCount++; + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤9-处理文档】✗ 处理文档失败: " . $e->getMessage() . "\n"); + LoggerHelper::logError($e, [ + 'component' => 'GenericCollectionHandler', + 'action' => 'processDocument', + 'task_id' => $taskId, + 'collection' => $collName, + ]); + } + } + + // 根据更新间隔决定是否更新进度 + if ($totalCount == 0 || ($processedCount - $lastUpdateCount) >= $updateInterval || $processedCount >= $totalCount) { + $percentage = $totalCount > 0 ? round(($processedCount / $totalCount) * 100, 2) : 0; + + // 检查是否达到100% + if ($totalCount > 0 && $processedCount >= $totalCount) { + // 进度达到100%,停止采集并更新状态为已完成 + $this->updateProgress($taskId, [ + 'status' => 'completed', + 'processed_count' => $processedCount, + 'success_count' => $successCount, + 'error_count' => $errorCount, + 'total_count' => $totalCount, + 'percentage' => 100, + 'end_time' => new \MongoDB\BSON\UTCDateTime(time() * 1000), + ]); + \Workerman\Worker::safeEcho("[GenericCollectionHandler] ✅ 采集完成,进度已达到100%,已停止采集\n"); + break 2; // 跳出两层循环(foreach 和 do-while) + } else { + $this->updateProgress($taskId, [ + 'processed_count' => $processedCount, + 'success_count' => $successCount, + 'error_count' => $errorCount, + 'total_count' => $totalCount, + 'percentage' => $percentage, + ]); + } + $lastUpdateCount = $processedCount; + } + + $offset += $batchSize; + + } while (count($batch) === $batchSize && $processedCount < $totalCount); + } + } + + // 更新进度:完成(如果循环正常结束,也更新状态为已完成) + $task = $this->taskService->getTask($taskId); + if ($task) { + // 只有在任务状态不是 completed、paused、stopped 时,才更新为 completed + // 如果任务被暂停或停止,不应该更新为 completed + if ($task['status'] === 'completed') { + // 已经是 completed,不需要更新 + } elseif (in_array($task['status'], ['paused', 'stopped'])) { + // 任务被暂停或停止,只更新进度,不更新状态 + \Workerman\Worker::safeEcho("[GenericCollectionHandler] ⚠️ 任务已被暂停或停止,不更新为completed状态\n"); + $percentage = $totalCount > 0 ? round(($processedCount / $totalCount) * 100, 2) : 0; + $this->updateProgress($taskId, [ + 'total_count' => $totalCount, + 'processed_count' => $processedCount, + 'success_count' => $successCount, + 'error_count' => $errorCount, + 'percentage' => $percentage, + ]); + } else { + // 任务正常完成,更新状态为 completed + $this->updateProgress($taskId, [ + 'status' => 'completed', + 'processed_count' => $processedCount, + 'success_count' => $successCount, + 'error_count' => $errorCount, + 'total_count' => $totalCount, + 'percentage' => 100, // 完成时强制设置为100% + 'end_time' => new \MongoDB\BSON\UTCDateTime(time() * 1000), + ]); + \Workerman\Worker::safeEcho("[GenericCollectionHandler] ✅ 采集任务完成,状态已更新为completed\n"); + } + } + + LoggerHelper::logBusiness('generic_collection_completed', [ + 'task_id' => $taskId, + 'processed' => $processedCount, + 'success' => $successCount, + 'error' => $errorCount, + ]); + } + + /** + * 实时监听 + */ + private function watchCollection(array $taskConfig): void + { + $taskId = $taskConfig['task_id'] ?? ''; + $database = $taskConfig['database'] ?? ''; + $collection = $taskConfig['collection'] ?? null; + $collections = $taskConfig['collections'] ?? null; + $fieldMappings = $taskConfig['field_mappings'] ?? []; + + // 更新进度:开始 + $this->updateProgress($taskId, [ + 'status' => 'running', + 'start_time' => new \MongoDB\BSON\UTCDateTime(time() * 1000), + ]); + + $client = $this->getMongoClient($taskConfig); + $db = $client->selectDatabase($database); + + // 如果指定了单个集合,监听该集合 + if ($collection) { + $coll = $db->selectCollection($collection); + $this->watchSingleCollection($coll, $fieldMappings, $taskConfig); + } elseif ($collections && is_array($collections)) { + // 如果指定了多个集合,监听数据库级别(然后过滤) + $this->watchMultipleCollections($db, $collections, $fieldMappings, $taskConfig); + } else { + throw new \InvalidArgumentException('实时模式必须指定 collection 或 collections'); + } + } + + /** + * 监听单个集合 + */ + private function watchSingleCollection(Collection $collection, array $fieldMappings, array $taskConfig): void + { + $taskId = $taskConfig['task_id'] ?? ''; + + $changeStream = $collection->watch( + [], + [ + 'fullDocument' => 'updateLookup', + 'batchSize' => 100, + 'maxAwaitTimeMS' => 1000, + ] + ); + + LoggerHelper::logBusiness('generic_collection_watch_ready', [ + 'task_id' => $taskId, + 'collection' => $collection->getCollectionName(), + ]); + + foreach ($changeStream as $change) { + if (!$this->checkTaskStatus($taskId)) { + break; // 任务已暂停或停止 + } + + try { + $operationType = $change['operationType'] ?? ''; + + if ($operationType === 'insert' || $operationType === 'update') { + $document = $change['fullDocument'] ?? null; + + if ($document === null && $operationType === 'update') { + $documentId = $change['documentKey']['_id'] ?? null; + if ($documentId !== null) { + $document = $collection->findOne(['_id' => $documentId]); + } + } + + if ($document !== null) { + $docData = $this->convertMongoDocumentToArray($document); + $this->processDocument($docData, $fieldMappings, $taskConfig); + + // 更新进度 + $this->updateProgress($taskId, [ + 'processed_count' => ['$inc' => 1], + 'success_count' => ['$inc' => 1], + ]); + } + } + } catch (\Exception $e) { + LoggerHelper::logError($e, [ + 'component' => 'GenericCollectionHandler', + 'action' => 'watchSingleCollection', + 'task_id' => $taskId, + ]); + + // 更新错误计数 + $this->updateProgress($taskId, [ + 'error_count' => ['$inc' => 1], + ]); + } + } + } + + /** + * 监听多个集合 + */ + private function watchMultipleCollections(Database $database, array $collections, array $fieldMappings, array $taskConfig): void + { + $taskId = $taskConfig['task_id'] ?? ''; + + $changeStream = $database->watch( + [], + [ + 'fullDocument' => 'updateLookup', + 'batchSize' => 100, + 'maxAwaitTimeMS' => 1000, + ] + ); + + LoggerHelper::logBusiness('generic_collection_watch_ready', [ + 'task_id' => $taskId, + 'collections' => $collections, + ]); + + foreach ($changeStream as $change) { + if (!$this->checkTaskStatus($taskId)) { + break; // 任务已暂停或停止 + } + + try { + $collectionName = $change['ns']['coll'] ?? ''; + + // 只处理配置的集合 + if (!in_array($collectionName, $collections)) { + continue; + } + + $operationType = $change['operationType'] ?? ''; + + if ($operationType === 'insert' || $operationType === 'update') { + $document = $change['fullDocument'] ?? null; + + if ($document === null && $operationType === 'update') { + $documentId = $change['documentKey']['_id'] ?? null; + if ($documentId !== null) { + $collection = $database->selectCollection($collectionName); + $document = $collection->findOne(['_id' => $documentId]); + } + } + + if ($document !== null) { + $docData = $this->convertMongoDocumentToArray($document); + $this->processDocument($docData, $fieldMappings, $taskConfig); + + // 更新进度 + $this->updateProgress($taskId, [ + 'processed_count' => ['$inc' => 1], + 'success_count' => ['$inc' => 1], + ]); + } + } + } catch (\Exception $e) { + LoggerHelper::logError($e, [ + 'component' => 'GenericCollectionHandler', + 'action' => 'watchMultipleCollections', + 'task_id' => $taskId, + ]); + + // 更新错误计数 + $this->updateProgress($taskId, [ + 'error_count' => ['$inc' => 1], + ]); + } + } + } + + /** + * 处理文档 + */ + private function processDocument(array $docData, array $fieldMappings, array $taskConfig): void + { + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤10-字段映射】开始应用字段映射,源字段数量: " . count(array_keys($docData)) . "\n"); + // 应用字段映射 + $mappedData = $this->applyFieldMappings($docData, $fieldMappings); + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤10-字段映射】✓ 字段映射完成,目标字段数量: " . count(array_keys($mappedData)) . "\n"); + + // 提取用户标识(优先使用user_id,否则使用phone_number或id_card) + $userId = $mappedData['user_id'] ?? null; + $phoneNumber = $mappedData['phone_number'] ?? null; + $idCard = $mappedData['id_card'] ?? null; + + // 如果既没有user_id,也没有phone_number和id_card,则跳过 + if (empty($userId) && empty($phoneNumber) && empty($idCard)) { + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤10-字段映射】✗ 跳过:缺少用户标识\n"); + LoggerHelper::logBusiness('generic_collection_skip_no_user_identifier', [ + 'task_id' => $taskConfig['task_id'] ?? '', + ]); + return; + } + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤10-字段映射】用户标识: user_id=" . ($userId ?? 'null') . ", phone=" . ($phoneNumber ?? 'null') . "\n"); + + // 店铺名称:优先保存从源数据映射的店铺名称(无论店铺表查询结果如何) + $storeName = $mappedData['store_name'] ?? null; + + // 调试:输出映射后的店铺名称(用于排查问题) + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤10-字段映射】映射后的店铺名称: " . ($storeName ?? 'null') . "\n"); + + // 如果映射后没有店铺名称,尝试从源数据中查找可能的店铺名称字段(作为后备方案) + if (empty($storeName)) { + // 常见的店铺名称字段名 + $possibleStoreNameFields = ['store_name', '门店名称', '店铺名称', '门店名', '店铺名', 'storeName', '门店', '店铺', '新零售成交门店昵称']; + foreach ($possibleStoreNameFields as $fieldName) { + $value = $docData[$fieldName] ?? null; + if (!empty($value)) { + $storeName = $value; + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤10-字段映射】从源数据中提取店铺名称: {$fieldName} = {$storeName}\n"); + break; + } + } + } + + // 处理门店ID:如果提供了store_name但没有store_id,则通过门店服务获取或创建 + // 注意:即使获取store_id失败,也要保存store_name + $storeId = $mappedData['store_id'] ?? null; + if (empty($storeId) && !empty($storeName)) { + try { + $source = $taskConfig['data_source_id'] ?? $taskConfig['name'] ?? 'unknown'; + $storeId = $this->storeService->getOrCreateStoreByName( + $storeName, + $source + ); + } catch (\Throwable $e) { + // 店铺ID获取失败不影响店铺名称的保存,只记录日志 + LoggerHelper::logError($e, [ + 'component' => 'GenericCollectionHandler', + 'action' => 'processDocument', + 'message' => '获取店铺ID失败,但会继续保存店铺名称', + 'store_name' => $storeName, + ]); + } + } + + // 构建消费记录数据 + $recordData = [ + 'consume_time' => $mappedData['consume_time'] ?? date('Y-m-d H:i:s'), + 'amount' => $mappedData['amount'] ?? 0, + 'actual_amount' => $mappedData['actual_amount'] ?? $mappedData['amount'] ?? 0, + 'currency' => $mappedData['currency'] ?? 'CNY', + 'status' => $mappedData['status'] ?? 0, + ]; + + // 添加用户标识(优先使用user_id,否则使用phone_number或id_card) + if (!empty($userId)) { + $recordData['user_id'] = $userId; + } elseif (!empty($phoneNumber)) { + $recordData['phone_number'] = $phoneNumber; + } elseif (!empty($idCard)) { + $recordData['id_card'] = $idCard; + } + + // 添加门店ID(如果已转换或已提供) + if (!empty($storeId)) { + $recordData['store_id'] = $storeId; + } + + // 添加店铺名称(优先保存从源数据映射的店铺名称) + if (!empty($storeName)) { + $recordData['store_name'] = $storeName; + } + + // 根据任务配置保存到目标数据源 + $targetType = $taskConfig['target_type'] ?? 'generic'; + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤11-保存数据】开始保存数据到目标数据源: target_type={$targetType}\n"); + + if ($targetType === 'consumption_record') { + // 消费记录类型:使用 ConsumptionService(它会写入到目标数据源) + // 但需要确保 ConsumptionService 使用正确的数据源连接 + $this->saveToTargetDataSource($recordData, $taskConfig, 'consumption_record'); + } else { + // 通用类型:直接保存到指定的目标数据源、数据库、集合 + $this->saveToTargetDataSource($recordData, $taskConfig, 'generic'); + } + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤11-保存数据】✓ 数据保存完成\n"); + } + + /** + * 保存数据到目标数据源 + * + * @param array $data 要保存的数据 + * @param array $taskConfig 任务配置 + * @param string $targetType 目标类型(consumption_record 或 generic) + * @return void + */ + private function saveToTargetDataSource(array $data, array $taskConfig, string $targetType): void + { + $targetDataSourceId = $taskConfig['target_data_source_id'] ?? null; + $targetDatabase = $taskConfig['target_database'] ?? null; + $targetCollection = $taskConfig['target_collection'] ?? null; + + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤12-连接目标数据源】目标数据源ID={$targetDataSourceId}, 目标数据库={$targetDatabase}, 目标集合={$targetCollection}\n"); + + if (empty($targetDataSourceId)) { + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤12-连接目标数据源】✗ 缺少target_data_source_id\n"); + throw new \InvalidArgumentException('任务配置中缺少 target_data_source_id'); + } + + // 连接到目标数据源 + // \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤12-连接目标数据源】开始查询目标数据源配置\n"); + $connectionInfo = $this->connectToTargetDataSource($targetDataSourceId, $targetDatabase); + $targetDataSourceConfig = $connectionInfo['config']; + $dbName = $connectionInfo['dbName']; + $database = $connectionInfo['database']; + + // \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤12-连接目标数据源】✓ 目标数据源配置查询成功: host={$targetDataSourceConfig['host']}, port={$targetDataSourceConfig['port']}\n"); + + // 确定目标集合 + if ($targetType === 'consumption_record') { + // 消费记录类型:集合名为 consumption_records(按月份分表) + $collectionName = 'consumption_records'; + + // 如果有 consume_time,根据时间确定月份集合 + if (isset($data['consume_time'])) { + try { + $consumeTime = new \DateTimeImmutable($data['consume_time']); + $monthSuffix = $consumeTime->format('Ym'); + $collectionName = "consumption_records_{$monthSuffix}"; + } catch (\Exception $e) { + // 如果解析失败,使用当前月份 + $collectionName = 'consumption_records_' . date('Ym'); + } + } else { + $collectionName = 'consumption_records_' . date('Ym'); + } + + // 对于消费记录,需要先通过 ConsumptionService 处理(解析用户ID等) + // 但需要确保它写入到正确的数据源 + // 这里我们直接写入,但需要先解析用户ID + if (empty($data['user_id']) && (!empty($data['phone_number']) || !empty($data['id_card']))) { + // 需要解析用户ID + $userId = $this->identifierService->resolvePersonId( + $data['phone_number'] ?? null, + $data['id_card'] ?? null + ); + $data['user_id'] = $userId; + } + + // 确保有 record_id + if (empty($data['record_id'])) { + $data['record_id'] = \Ramsey\Uuid\Uuid::uuid4()->toString(); + } + + // 转换时间字段 + if (isset($data['consume_time']) && is_string($data['consume_time'])) { + $data['consume_time'] = new \MongoDB\BSON\UTCDateTime(strtotime($data['consume_time']) * 1000); + } + if (empty($data['create_time'])) { + $data['create_time'] = new \MongoDB\BSON\UTCDateTime(time() * 1000); + } elseif (is_string($data['create_time'])) { + $data['create_time'] = new \MongoDB\BSON\UTCDateTime(strtotime($data['create_time']) * 1000); + } + } else { + // 通用类型:使用任务配置中的目标集合 + if (empty($targetCollection)) { + throw new \InvalidArgumentException('通用类型任务必须配置 target_collection'); + } + $collectionName = $targetCollection; + } + + // 写入数据 + $collection = $database->selectCollection($collectionName); + + // 对于消费记录类型,基于业务唯一标识检查是否已存在(防止重复插入) + if ($targetType === 'consumption_record') { + // 转换 consume_time 为 UTCDateTime(如果存在) + $consumeTimeForQuery = null; + if (isset($data['consume_time'])) { + if (is_string($data['consume_time'])) { + $consumeTimeForQuery = new \MongoDB\BSON\UTCDateTime(strtotime($data['consume_time']) * 1000); + } elseif ($data['consume_time'] instanceof \MongoDB\BSON\UTCDateTime) { + $consumeTimeForQuery = $data['consume_time']; + } + } + + // 获取店铺名称(用于去重) + // 优先使用从源数据映射的店铺名称,无论店铺表查询结果如何 + $storeName = $data['store_name'] ?? null; + + // 如果源数据中没有 store_name 但有 store_id,尝试从店铺表获取店铺名称(作为后备方案) + // 但即使查询失败,也要确保 store_name 字段被保存(可能为 null) + if (empty($storeName) && !empty($data['store_id'])) { + try { + $store = $this->storeService->getStoreById($data['store_id']); + if ($store && $store->store_name) { + $storeName = $store->store_name; + } + } catch (\Throwable $e) { + // 从店铺表反查失败不影响数据保存,只记录日志 + LoggerHelper::logError($e, [ + 'component' => 'GenericCollectionHandler', + 'action' => 'saveToTargetDataSource', + 'message' => '从店铺表反查店铺名称失败,将保存null值', + 'store_id' => $data['store_id'] ?? null, + ]); + } + } + + // 确保 store_name 字段被保存到 data 中(即使为 null 也要保存,保持数据结构一致) + $data['store_name'] = $storeName; + + // 基于业务唯一标识检查重复(防止重复插入) + // 方案:使用 store_name + source_order_id 作为唯一标识 + // 注意:order_no 是系统自动生成的(自动递增),不参与去重判断 + $duplicateQuery = null; + $duplicateIdentifier = null; + $sourceOrderId = $data['source_order_id'] ?? null; + + if (!empty($storeName) && !empty($sourceOrderId)) { + // 使用店铺名称 + 原始订单ID作为唯一标识 + $duplicateQuery = [ + 'store_name' => $storeName, + 'source_order_id' => $sourceOrderId, + ]; + $duplicateIdentifier = "store_name={$storeName}, source_order_id={$sourceOrderId}"; + } + + // 如果找到了唯一标识,检查是否已存在 + if ($duplicateQuery) { + $existingRecord = $collection->findOne($duplicateQuery); + if ($existingRecord) { + \Workerman\Worker::safeEcho("[GenericCollectionHandler] ⚠️ 消费记录已存在,跳过插入: {$duplicateIdentifier}, collection={$collectionName}\n"); + LoggerHelper::logBusiness('generic_collection_duplicate_skipped', [ + 'task_id' => $taskId, + 'duplicate_identifier' => $duplicateIdentifier, + 'target_collection' => $collectionName, + ]); + return; // 跳过重复记录 + } + } + + // 生成 record_id(如果还没有) + // 使用店铺名称 + 原始订单ID生成稳定的 record_id + if (empty($data['record_id'])) { + if (!empty($storeName) && !empty($sourceOrderId)) { + // 使用店铺名称 + 原始订单ID生成稳定的 record_id + $uniqueKey = "{$storeName}|{$sourceOrderId}"; + $data['record_id'] = 'store_source_' . md5($uniqueKey); + } else { + // 如果都没有,生成 UUID + $data['record_id'] = \Ramsey\Uuid\Uuid::uuid4()->toString(); + } + } + + // 生成 order_no(系统自动生成,自动递增) + // 注意:order_no 不参与去重判断,仅用于展示和查询 + // 使用计数器集合来生成唯一的 order_no(在去重检查之后,只有实际插入的记录才生成 order_no) + if (empty($data['order_no'])) { + try { + // 使用计数器集合来生成唯一的 order_no + $counterCollection = $database->selectCollection($collectionName . '_counter'); + + // 原子性地递增计数器 + $counterResult = $counterCollection->findOneAndUpdate( + ['_id' => 'order_no'], + ['$inc' => ['seq' => 1], '$setOnInsert' => ['_id' => 'order_no', 'seq' => 1]], + ['upsert' => true, 'returnDocument' => 1] // 1 = RETURN_DOCUMENT_AFTER + ); + + $nextOrderNo = $counterResult['seq'] ?? 1; + $data['order_no'] = (string)$nextOrderNo; + } catch (\Throwable $e) { + // 如果计数器操作失败,回退到查询最大值的方案 + LoggerHelper::logError($e, [ + 'component' => 'GenericCollectionHandler', + 'action' => 'saveToTargetDataSource', + 'message' => '使用计数器生成order_no失败,回退到查询最大值方案', + ]); + + try { + $maxOrderNo = $collection->findOne( + [], + ['sort' => ['order_no' => -1], 'projection' => ['order_no' => 1]] + ); + $nextOrderNo = 1; + if ($maxOrderNo && isset($maxOrderNo['order_no']) && is_numeric($maxOrderNo['order_no'])) { + $nextOrderNo = (int)$maxOrderNo['order_no'] + 1; + } + $data['order_no'] = (string)$nextOrderNo; + } catch (\Throwable $e2) { + // 如果查询也失败,使用时间戳作为备选方案 + $data['order_no'] = (string)(time() * 1000 + mt_rand(1000, 9999)); + LoggerHelper::logError($e2, [ + 'component' => 'GenericCollectionHandler', + 'action' => 'saveToTargetDataSource', + 'message' => '查询最大order_no也失败,使用时间戳作为备选', + ]); + } + } + } + + // 确保 consume_time 是 UTCDateTime 类型 + if ($consumeTimeForQuery) { + $data['consume_time'] = $consumeTimeForQuery; + } + } + + // 格式化输出流水信息 + $timestamp = date('Y-m-d H:i:s'); + $taskId = $taskConfig['task_id'] ?? 'unknown'; + $recordId = $data['record_id'] ?? $data['_id'] ?? 'auto'; + $userId = $data['user_id'] ?? 'null'; + $phoneNumber = $data['phone_number'] ?? $data['phone'] ?? 'null'; + + // 提取关键字段用于显示(最多显示10个字段) + $keyFields = []; + $fieldCount = 0; + foreach ($data as $key => $value) { + if ($fieldCount >= 10) break; + if (in_array($key, ['_id', 'record_id', 'user_id', 'phone_number', 'phone'])) continue; + if (is_array($value) || is_object($value)) { + $keyFields[] = "{$key}: " . json_encode($value, JSON_UNESCAPED_UNICODE); + } else { + $keyFields[] = "{$key}: {$value}"; + } + $fieldCount++; + } + $keyFieldsStr = !empty($keyFields) ? implode(', ', $keyFields) : '(无其他字段)'; + + // 输出详细的插入流水信息(输出到终端) + $output = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + . "📝 [{$timestamp}] 通用数据插入流水 | 任务ID: {$taskId}\n" + . " ├─ 记录ID: {$recordId}\n" + . " ├─ 用户ID: {$userId}\n" + . " ├─ 手机号: {$phoneNumber}\n" + . " ├─ 关键字段: {$keyFieldsStr}\n" + . " ├─ 目标数据库: {$dbName}\n" + . " └─ 目标集合: {$collectionName}\n"; + + \Workerman\Worker::safeEcho($output); + + $result = $collection->insertOne($data); + $insertedId = $result->getInsertedId(); + + $successOutput = " ✅ 插入成功 | MongoDB ID: " . (is_object($insertedId) ? (string)$insertedId : json_encode($insertedId)) . "\n" + . "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"; + + \Workerman\Worker::safeEcho($successOutput); + + LoggerHelper::logBusiness('generic_collection_data_saved', [ + 'task_id' => $taskConfig['task_id'] ?? '', + 'target_data_source_id' => $targetDataSourceId, + 'target_database' => $dbName, + 'target_collection' => $collectionName, + 'inserted_id' => (string)$insertedId, + ]); + } + + /** + * 应用字段映射 + * + * 注意:如果源字段或目标字段为空(空字符串、null、未设置),则跳过该映射 + * 这样用户可以清除源字段选择,表示不需要映射该目标字段 + */ + private function applyFieldMappings(array $sourceData, array $fieldMappings): array + { + $mappedData = []; + + foreach ($fieldMappings as $mapping) { + $sourceField = $mapping['source_field'] ?? ''; + $targetField = $mapping['target_field'] ?? ''; + $transform = $mapping['transform'] ?? null; + + // 如果源字段或目标字段为空,跳过该映射(兼容用户清除源字段选择的情况) + if (empty($sourceField) || empty($targetField)) { + continue; + } + + // 从源数据中获取值(支持嵌套字段,如 "user.name") + $value = $this->getNestedValue($sourceData, $sourceField); + + // 调试:输出字段映射详情(仅对关键字段) + if ($targetField === 'store_name') { + \Workerman\Worker::safeEcho("[GenericCollectionHandler] 【通用-步骤10-字段映射】字段映射详情: target={$targetField}, source={$sourceField}, value=" . ($value ?? 'null') . "\n"); + } + + // 应用转换函数 + if ($transform && is_callable($transform)) { + $value = $transform($value); + } elseif ($transform && is_string($transform)) { + $value = $this->applyTransform($value, $transform); + } + + $mappedData[$targetField] = $value; + } + + return $mappedData; + } + + /** + * 获取嵌套字段值 + */ + private function getNestedValue(array $data, string $fieldPath) + { + $parts = explode('.', $fieldPath); + $value = $data; + + foreach ($parts as $part) { + if (is_array($value) && isset($value[$part])) { + $value = $value[$part]; + } elseif (is_object($value) && isset($value->$part)) { + $value = $value->$part; + } else { + return null; + } + } + + return $value; + } + + /** + * 应用转换函数 + */ + private function applyTransform($value, string $transform) + { + switch ($transform) { + case 'parse_amount': + return $this->parseAmount($value); + case 'parse_datetime': + return $this->parseDateTimeToString($value); + case 'parse_phone': + return $this->extractPhoneNumber(['phone' => $value]); + default: + return $value; + } + } + + /** + * 提取手机号(通用数据专用) + * + * 注意:这个方法保留在此类中,因为它处理的是通用数据的字段名 + * 订单数据的手机号提取在 ConsumptionCollectionHandler 中 + */ + private function extractPhoneNumber(array $data): ?string + { + // 尝试多个可能的字段名 + $phoneFields = ['phone_number', 'phone', 'mobile', 'tel', 'contact_phone']; + + foreach ($phoneFields as $field) { + if (isset($data[$field])) { + $phone = trim((string)$data[$field]); + // 先过滤非数字字符 + $cleanedPhone = $this->filterPhoneNumber($phone); + if (!empty($cleanedPhone) && $this->isValidPhone($cleanedPhone)) { + // 返回过滤后的手机号 + return $cleanedPhone; + } + } + } + + return null; + } + + /** + * 解析日期时间为字符串(用于通用数据保存) + * + * 注意:这个方法与 Trait 中的 parseDateTime 不同,它返回字符串格式 + */ + private function parseDateTimeToString($dateTimeStr): string + { + if (empty($dateTimeStr)) { + return date('Y-m-d H:i:s'); + } + + $dateTime = $this->parseDateTime($dateTimeStr); + if ($dateTime === null) { + return date('Y-m-d H:i:s'); + } + + return $dateTime->format('Y-m-d H:i:s'); + } + + /** + * 构建过滤条件 + */ + private function buildFilter(array $filterConditions): array + { + $filter = []; + + foreach ($filterConditions as $condition) { + $field = $condition['field'] ?? ''; + $operator = $condition['operator'] ?? 'eq'; + $value = $condition['value'] ?? null; + + if (empty($field)) { + continue; + } + + switch ($operator) { + case 'eq': + $filter[$field] = $value; + break; + case 'ne': + $filter[$field] = ['$ne' => $value]; + break; + case 'gt': + $filter[$field] = ['$gt' => $value]; + break; + case 'gte': + $filter[$field] = ['$gte' => $value]; + break; + case 'lt': + $filter[$field] = ['$lt' => $value]; + break; + case 'lte': + $filter[$field] = ['$lte' => $value]; + break; + case 'in': + $filter[$field] = ['$in' => $value]; + break; + case 'nin': + $filter[$field] = ['$nin' => $value]; + break; + } + } + + return $filter; + } + + /** + * 检查任务状态 + */ + private function checkTaskStatus(string $taskId): bool + { + // 检查Redis标志 + if (\app\utils\RedisHelper::exists("data_collection_task:{$taskId}:pause")) { + return false; + } + if (\app\utils\RedisHelper::exists("data_collection_task:{$taskId}:stop")) { + return false; + } + + // 检查数据库状态 + $task = $this->taskService->getTask($taskId); + if ($task && in_array($task['status'], ['paused', 'stopped', 'error'])) { + return false; + } + + return true; + } + + /** + * 更新进度 + */ + /** + * 根据总记录数计算合适的进度更新间隔 + * + * @param int $totalCount 总记录数 + * @return int 更新间隔(每处理多少条记录更新一次) + */ + private function calculateProgressUpdateInterval(int $totalCount): int + { + // 根据总数动态调整更新间隔,确保既不会太频繁也不会太慢 + // 策略:大约每1%更新一次,但限制在合理范围内 + + if ($totalCount <= 0) { + return 50; // 默认50条 + } + + // 计算1%的数量 + $onePercent = max(1, (int)($totalCount * 0.01)); + + // 根据总数范围调整: + // - 小于1000条:每50条更新(保证至少更新20次) + // - 1000-10000条:每1%更新(约10-100条) + // - 10000-100000条:每1%更新(约100-1000条) + // - 100000-1000000条:每1%更新(约1000-10000条),但最多5000条 + // - 大于1000000条:每5000条更新(避免更新太频繁) + + if ($totalCount < 1000) { + return 50; + } elseif ($totalCount < 10000) { + return max(50, min(500, $onePercent)); + } elseif ($totalCount < 100000) { + return max(100, min(1000, $onePercent)); + } elseif ($totalCount < 1000000) { + return max(500, min(5000, $onePercent)); + } else { + return 5000; // 大数据量固定5000条更新一次 + } + } + + private function updateProgress(string $taskId, array $progress): void + { + try { + $task = $this->taskService->getTask($taskId); + if (!$task) { + return; + } + + $currentProgress = $task['progress'] ?? []; + + // 检查是否需要更新任务状态 + $updateTaskStatus = false; + $newStatus = null; + if (isset($progress['status'])) { + $updateTaskStatus = true; + $newStatus = $progress['status']; + unset($progress['status']); // 从progress中移除,单独处理 + } + + // 处理增量更新 + foreach ($progress as $key => $value) { + if (is_array($value) && isset($value['$inc'])) { + $currentProgress[$key] = ($currentProgress[$key] ?? 0) + $value['$inc']; + } else { + $currentProgress[$key] = $value; + } + } + + // 确保 percentage 字段存在且正确计算(基于已采集条数/总条数) + if (isset($currentProgress['processed_count']) && isset($currentProgress['total_count'])) { + if ($currentProgress['total_count'] > 0) { + // 进度 = 已采集条数 / 总条数 * 100 + $currentProgress['percentage'] = round( + ($currentProgress['processed_count'] / $currentProgress['total_count']) * 100, + 2 + ); + // 确保不超过100% + $currentProgress['percentage'] = min(100, $currentProgress['percentage']); + } else { + $currentProgress['percentage'] = 0; + } + } + + // 更新进度到数据库 + $this->taskService->updateProgress($taskId, $currentProgress); + + // 如果指定了状态,更新任务状态(例如:completed) + if ($updateTaskStatus && $newStatus !== null) { + $this->taskService->updateTask($taskId, ['status' => $newStatus]); + } + } catch (\Exception $e) { + LoggerHelper::logError($e, [ + 'component' => 'GenericCollectionHandler', + 'action' => 'updateProgress', + 'task_id' => $taskId, + ]); + } + } + + + /** + * 获取集合的字段映射(优先使用集合级映射,否则使用全局映射) + * + * @param string $collectionName 集合名称 + * @param array $taskConfig 任务配置 + * @return array 字段映射配置 + */ + private function getFieldMappingsForCollection(string $collectionName, array $taskConfig): array + { + // 优先使用集合级映射 + $collectionMappings = $taskConfig['collection_field_mappings'][$collectionName] ?? null; + if ($collectionMappings !== null && is_array($collectionMappings)) { + return $collectionMappings; + } + + // 回退到全局映射 + return $taskConfig['field_mappings'] ?? []; + } + + /** + * 获取集合的连表查询配置(优先使用集合级配置,否则使用全局配置) + * + * @param string $collectionName 集合名称 + * @param array $taskConfig 任务配置 + * @return array 连表查询配置 + */ + private function getLookupsForCollection(string $collectionName, array $taskConfig): array + { + // 优先使用集合级连表查询配置 + $collectionLookups = $taskConfig['collection_lookups'][$collectionName] ?? null; + if ($collectionLookups !== null && is_array($collectionLookups)) { + return $collectionLookups; + } + + // 回退到全局连表查询配置(单集合模式) + return $taskConfig['lookups'] ?? []; + } + + /** + * 使用连表查询采集数据 + * + * @param Collection $collection MongoDB集合对象 + * @param string $collectionName 集合名称 + * @param array $fieldMappings 字段映射配置 + * @param array $lookups 连表查询配置 + * @param array $filterConditions 过滤条件 + * @param array $taskConfig 任务配置 + * @param string $taskId 任务ID + * @return array{processed: int, success: int, error: int} 处理结果统计 + */ + private function collectWithLookup( + Collection $collection, + string $collectionName, + array $fieldMappings, + array $lookups, + array $filterConditions, + array $taskConfig, + string $taskId + ): array { + $processedCount = 0; + $successCount = 0; + $errorCount = 0; + $batchSize = $taskConfig['batch_size'] ?? 1000; + + // 构建聚合管道 + $pipeline = $this->buildAggregationPipeline($filterConditions, $lookups); + + LoggerHelper::logBusiness('generic_collection_lookup_start', [ + 'task_id' => $taskId, + 'collection' => $collectionName, + 'lookups' => $lookups, + ]); + + $offset = 0; + do { + if (!$this->checkTaskStatus($taskId)) { + break; // 任务已暂停或停止 + } + + // 构建分页管道 + $pagedPipeline = $pipeline; + if ($offset > 0) { + $pagedPipeline[] = ['$skip' => $offset]; + } + $pagedPipeline[] = ['$limit' => $batchSize]; + + // 执行聚合查询 + $cursor = $collection->aggregate($pagedPipeline); + + $batch = []; + foreach ($cursor as $doc) { + $batch[] = $this->convertMongoDocumentToArray($doc); + } + + if (empty($batch)) { + break; + } + + // 处理批量数据 + foreach ($batch as $docData) { + $processedCount++; + try { + $this->processDocument($docData, $fieldMappings, $taskConfig); + $successCount++; + } catch (\Exception $e) { + $errorCount++; + LoggerHelper::logError($e, [ + 'component' => 'GenericCollectionHandler', + 'action' => 'processDocument_lookup', + 'task_id' => $taskId, + 'collection' => $collectionName, + ]); + } + } + + // 更新进度 + $this->updateProgress($taskId, [ + 'processed_count' => $processedCount, + 'success_count' => $successCount, + 'error_count' => $errorCount, + ]); + + $offset += $batchSize; + + } while (count($batch) === $batchSize); + + LoggerHelper::logBusiness('generic_collection_lookup_completed', [ + 'task_id' => $taskId, + 'collection' => $collectionName, + 'processed' => $processedCount, + 'success' => $successCount, + 'error' => $errorCount, + ]); + + return [ + 'processed' => $processedCount, + 'success' => $successCount, + 'error' => $errorCount, + ]; + } + + /** + * 构建MongoDB聚合管道(支持连表查询) + * + * @param array $filterConditions 过滤条件 + * @param array $lookups 连表查询配置 + * @return array MongoDB聚合管道 + */ + private function buildAggregationPipeline(array $filterConditions, array $lookups): array + { + $pipeline = []; + + // 1. 匹配条件($match) + $filter = $this->buildFilter($filterConditions); + if (!empty($filter)) { + $pipeline[] = ['$match' => $filter]; + } + + // 2. 连表查询($lookup) + foreach ($lookups as $lookup) { + $from = $lookup['from'] ?? ''; + $localField = $lookup['local_field'] ?? ''; + $foreignField = $lookup['foreign_field'] ?? ''; + $as = $lookup['as'] ?? 'joined'; + + if (empty($from) || empty($localField) || empty($foreignField)) { + LoggerHelper::logBusiness('generic_collection_lookup_invalid', [ + 'lookup' => $lookup, + ]); + continue; + } + + // 构建 $lookup 阶段 + $lookupStage = [ + '$lookup' => [ + 'from' => $from, + 'localField' => $localField, + 'foreignField' => $foreignField, + 'as' => $as, + ], + ]; + $pipeline[] = $lookupStage; + + // 如果配置了解构(unwrap),添加 $unwind 阶段 + if ($lookup['unwrap'] ?? false) { + $pipeline[] = [ + '$unwind' => [ + 'path' => '$' . $as, + 'preserveNullAndEmptyArrays' => $lookup['preserve_null'] ?? true, // 默认保留没有关联的记录 + ], + ]; + } + } + + return $pipeline; + } +} + diff --git a/app/service/DataCollection/Handler/TagTaskHandler.php b/app/service/DataCollection/Handler/TagTaskHandler.php new file mode 100644 index 0000000..27112ba --- /dev/null +++ b/app/service/DataCollection/Handler/TagTaskHandler.php @@ -0,0 +1,74 @@ + $taskConfig 任务配置 + * @return void + */ + public function collect($adapter, array $taskConfig): void + { + $taskId = $taskConfig['task_id'] ?? ''; + $taskName = $taskConfig['name'] ?? '标签任务'; + + LoggerHelper::logBusiness('tag_task_handler_started', [ + 'task_id' => $taskId, + 'task_name' => $taskName, + ]); + + try { + // 创建TagTaskService实例 + $tagTaskService = new TagTaskService( + new TagTaskRepository(), + new TagTaskExecutionRepository(), + new UserProfileRepository(), + new TagService( + new TagDefinitionRepository(), + new UserProfileRepository(), + new UserTagRepository(), + new TagHistoryRepository(), + new SimpleRuleEngine() + ) + ); + + // 执行任务 + $tagTaskService->executeTask($taskId); + + LoggerHelper::logBusiness('tag_task_handler_completed', [ + 'task_id' => $taskId, + 'task_name' => $taskName, + ]); + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'TagTaskHandler', + 'action' => 'collect', + 'task_id' => $taskId, + ]); + throw $e; + } + } +} + diff --git a/app/service/DataCollection/Handler/Trait/DataCollectionHelperTrait.php b/app/service/DataCollection/Handler/Trait/DataCollectionHelperTrait.php new file mode 100644 index 0000000..44ecfd6 --- /dev/null +++ b/app/service/DataCollection/Handler/Trait/DataCollectionHelperTrait.php @@ -0,0 +1,172 @@ + 数组格式的数据 + */ + protected function convertMongoDocumentToArray($document): array + { + if (is_array($document)) { + return $document; + } + + if (is_object($document) && method_exists($document, 'toArray')) { + return $document->toArray(); + } + + return json_decode(json_encode($document), true) ?? []; + } + + /** + * 解析日期时间字符串 + * + * @param mixed $dateTimeStr 日期时间字符串或对象 + * @return \DateTimeImmutable|null 解析后的日期时间对象 + */ + protected function parseDateTime($dateTimeStr): ?\DateTimeImmutable + { + if (empty($dateTimeStr)) { + return null; + } + + // 如果是 MongoDB 的 UTCDateTime 对象 + if ($dateTimeStr instanceof UTCDateTime) { + return \DateTimeImmutable::createFromMutable($dateTimeStr->toDateTime()); + } + + // 如果是 DateTime 对象 + if ($dateTimeStr instanceof \DateTime || $dateTimeStr instanceof \DateTimeImmutable) { + if ($dateTimeStr instanceof \DateTime) { + return \DateTimeImmutable::createFromMutable($dateTimeStr); + } + return $dateTimeStr; + } + + // 尝试解析字符串 + try { + return new \DateTimeImmutable((string)$dateTimeStr); + } catch (\Exception $e) { + \app\utils\LoggerHelper::logBusiness('datetime_parse_failed', [ + 'input' => $dateTimeStr, + 'error' => $e->getMessage(), + ]); + return null; + } + } + + /** + * 解析金额 + * + * @param mixed $amount 金额字符串或数字 + * @return float 解析后的金额 + */ + protected function parseAmount($amount): float + { + if (is_numeric($amount)) { + return (float)$amount; + } + + if (is_string($amount)) { + // 移除所有非数字字符(除了小数点) + $cleaned = preg_replace('/[^\d.]/', '', $amount); + return (float)$cleaned; + } + + return 0.0; + } + + /** + * 过滤手机号中的非数字字符 + * + * @param string $phoneNumber 原始手机号 + * @return string 过滤后的手机号(只包含数字) + */ + protected function filterPhoneNumber(string $phoneNumber): string + { + // 移除所有非数字字符 + return preg_replace('/\D/', '', $phoneNumber); + } + + /** + * 验证手机号格式 + * + * @param string $phone 手机号(已经过滤过非数字字符) + * @return bool 是否有效(11位数字,1开头) + */ + protected function isValidPhone(string $phone): bool + { + // 如果为空,直接返回 false + if (empty($phone)) { + return false; + } + + // 中国大陆手机号:11位数字,以1开头 + return preg_match('/^1[3-9]\d{9}$/', $phone) === 1; + } + + /** + * 根据消费时间生成月份集合名 + * + * @param string $baseCollectionName 基础集合名 + * @param mixed $dateTimeStr 日期时间字符串或对象 + * @return string 带月份后缀的集合名(如:consumption_records_202512) + */ + protected function getMonthlyCollectionName(string $baseCollectionName, $dateTimeStr = null): string + { + $consumeTime = $this->parseDateTime($dateTimeStr); + if ($consumeTime === null) { + $consumeTime = new \DateTimeImmutable(); + } + $monthSuffix = $consumeTime->format('Ym'); + return "{$baseCollectionName}_{$monthSuffix}"; + } + + /** + * 转换为 MongoDB UTCDateTime + * + * @param mixed $dateTimeStr 日期时间字符串或对象 + * @return UTCDateTime|null MongoDB UTCDateTime 对象 + */ + protected function convertToUTCDateTime($dateTimeStr): ?UTCDateTime + { + if (empty($dateTimeStr)) { + return null; + } + + // 如果已经是 UTCDateTime,直接返回 + if ($dateTimeStr instanceof UTCDateTime) { + return $dateTimeStr; + } + + // 如果是 DateTime 对象 + if ($dateTimeStr instanceof \DateTime || $dateTimeStr instanceof \DateTimeImmutable) { + return new UTCDateTime($dateTimeStr->getTimestamp() * 1000); + } + + // 尝试解析字符串 + try { + $dateTime = new \DateTimeImmutable((string)$dateTimeStr); + return new UTCDateTime($dateTime->getTimestamp() * 1000); + } catch (\Exception $e) { + \app\utils\LoggerHelper::logBusiness('convert_to_utcdatetime_failed', [ + 'input' => $dateTimeStr, + 'error' => $e->getMessage(), + ]); + return null; + } + } +} + diff --git a/app/service/DataCollectionTaskService.php b/app/service/DataCollectionTaskService.php new file mode 100644 index 0000000..1c89a9b --- /dev/null +++ b/app/service/DataCollectionTaskService.php @@ -0,0 +1,660 @@ + $taskData 任务数据 + * @return array 创建的任务信息 + */ + public function createTask(array $taskData): array + { + // 生成任务ID + $taskId = UuidGenerator::uuid4()->toString(); + + // 根据Handler类型自动处理目标数据源配置 + $targetType = $taskData['target_type'] ?? ''; + $targetDataSourceId = $taskData['target_data_source_id'] ?? ''; + $targetDatabase = $taskData['target_database'] ?? ''; + $targetCollection = $taskData['target_collection'] ?? ''; + + if ($targetType === 'consumption_record') { + // 消费记录Handler:自动使用标签数据库配置 + $dataSourceService = new \app\service\DataSourceService(new \app\repository\DataSourceRepository()); + $dataSources = $dataSourceService->getDataSourceList(['status' => 1]); + + // 查找标签数据库数据源(通过名称或ID匹配) + $tagDataSource = null; + foreach ($dataSources['list'] ?? [] as $ds) { + $dsName = strtolower($ds['name'] ?? ''); + $dsId = strtolower($ds['data_source_id'] ?? ''); + if ($dsId === 'tag_mongodb' || + $dsName === 'tag_mongodb' || + stripos($dsName, '标签') !== false || + stripos($dsName, 'tag') !== false) { + $tagDataSource = $ds; + break; + } + } + + if ($tagDataSource) { + $targetDataSourceId = $tagDataSource['data_source_id']; + $targetDatabase = $tagDataSource['database'] ?? 'ckb'; + $targetCollection = 'consumption_records'; // 消费记录Handler会自动按时间分表 + } else { + // 如果找不到,使用默认值 + $targetDataSourceId = 'tag_mongodb'; // 尝试使用配置key作为ID + $targetDatabase = 'ckb'; + $targetCollection = 'consumption_records'; + } + } elseif ($targetType === 'generic') { + // 通用Handler:验证用户是否提供了配置 + if (empty($targetDataSourceId) || empty($targetDatabase) || empty($targetCollection)) { + throw new \InvalidArgumentException('通用Handler必须配置目标数据源、目标数据库和目标集合'); + } + } + + // 构建任务文档 + $task = [ + 'task_id' => $taskId, + 'name' => $taskData['name'] ?? '未命名任务', + 'description' => $taskData['description'] ?? '', + 'data_source_id' => $taskData['data_source_id'] ?? '', + 'database' => $taskData['database'] ?? '', + 'collection' => $taskData['collection'] ?? null, + 'collections' => $taskData['collections'] ?? null, + 'target_type' => $targetType, + 'target_data_source_id' => $targetDataSourceId, + 'target_database' => $targetDatabase, + 'target_collection' => $targetCollection, + 'mode' => $taskData['mode'] ?? 'batch', // batch: 批量采集, realtime: 实时监听 + 'field_mappings' => $this->cleanFieldMappings($taskData['field_mappings'] ?? []), + 'collection_field_mappings' => $taskData['collection_field_mappings'] ?? [], + 'lookups' => $taskData['lookups'] ?? [], + 'collection_lookups' => $taskData['collection_lookups'] ?? [], + 'filter_conditions' => $taskData['filter_conditions'] ?? [], + 'schedule' => $taskData['schedule'] ?? [ + 'enabled' => false, + 'cron' => null, + ], + 'status' => 'pending', // pending: 待启动, running: 运行中, paused: 已暂停, stopped: 已停止, error: 错误 + 'progress' => [ + 'status' => 'idle', // idle, running, paused, completed, error + 'processed_count' => 0, + 'success_count' => 0, + 'error_count' => 0, + 'total_count' => 0, + 'percentage' => 0, + 'start_time' => null, + 'end_time' => null, + 'last_sync_time' => null, + ], + 'statistics' => [ + 'total_processed' => 0, + 'total_success' => 0, + 'total_error' => 0, + 'last_run_time' => null, + ], + 'created_by' => $taskData['created_by'] ?? 'system', + ]; + + // 保存到数据库(使用原生MongoDB客户端,明确指定集合名) + // 注意:MongoDB Laravel的Model在数据中包含collection字段时,可能会误用该字段作为集合名 + // 因此使用原生客户端明确指定集合名为data_collection_tasks + $dbConfig = config('database.connections.mongodb'); + + // 使用 MongoDBHelper 创建客户端(统一DSN构建逻辑) + $client = \app\utils\MongoDBHelper::createClient([ + 'host' => parse_url($dbConfig['dsn'], PHP_URL_HOST) ?? '192.168.1.106', + 'port' => parse_url($dbConfig['dsn'], PHP_URL_PORT) ?? 27017, + 'username' => $dbConfig['username'] ?? '', + 'password' => $dbConfig['password'] ?? '', + 'auth_source' => $dbConfig['options']['authSource'] ?? 'admin', + ], array_filter($dbConfig['options'] ?? [], function ($value) { + return $value !== '' && $value !== null; + })); + + $database = $client->selectDatabase($dbConfig['database']); + $collection = $database->selectCollection('data_collection_tasks'); + + // 添加时间戳 + $task['created_at'] = new \MongoDB\BSON\UTCDateTime(time() * 1000); + $task['updated_at'] = new \MongoDB\BSON\UTCDateTime(time() * 1000); + + // 插入文档 + $result = $collection->insertOne($task); + + // 验证插入成功 + if ($result->getInsertedCount() !== 1) { + throw new \RuntimeException("任务创建失败:未能插入到数据库"); + } + + // 如果任务状态是 running,立即设置 Redis 启动标志,让调度器启动采集进程 + if ($task['status'] === 'running') { + try { + \app\utils\RedisHelper::set("data_collection_task:{$taskId}:start", '1', 3600); // 1小时过期 + LoggerHelper::logBusiness('data_collection_task_start_flag_set', [ + 'task_id' => $taskId, + 'task_name' => $task['name'], + ]); + } catch (\Throwable $e) { + // Redis 设置失败不影响任务创建,只记录日志 + LoggerHelper::logError($e, [ + 'component' => 'DataCollectionTaskService', + 'action' => 'createTask', + 'task_id' => $taskId, + 'message' => '设置启动标志失败', + ]); + } + } + + LoggerHelper::logBusiness('data_collection_task_created', [ + 'task_id' => $taskId, + 'task_name' => $task['name'], + ]); + + return $task; + } + + /** + * 清理字段映射数据,移除无效的映射项 + * + * @param array $fieldMappings 原始字段映射数组 + * @return array 清理后的字段映射数组 + */ + private function cleanFieldMappings(array $fieldMappings): array + { + $cleaned = []; + foreach ($fieldMappings as $mapping) { + // 如果缺少target_field,跳过该项 + if (empty($mapping['target_field'])) { + continue; + } + + // 清理状态值映射中的源状态值(移除多余的引号) + if (isset($mapping['value_mapping']) && is_array($mapping['value_mapping'])) { + foreach ($mapping['value_mapping'] as &$vm) { + if (isset($vm['source_value'])) { + // 移除字符串两端的单引号或双引号 + $vm['source_value'] = trim($vm['source_value'], "'\""); + } + } + unset($vm); // 解除引用 + } + + $cleaned[] = $mapping; + } + return $cleaned; + } + + /** + * 更新任务 + * + * @param string $taskId 任务ID + * @param array $taskData 任务数据 + * @return bool 是否更新成功 + */ + public function updateTask(string $taskId, array $taskData): bool + { + // 使用where查询,因为主键是task_id而不是_id + $task = $this->taskRepository->where('task_id', $taskId)->first(); + + if (!$task) { + throw new \InvalidArgumentException("任务不存在: {$taskId}"); + } + + // 如果任务正在运行,完全禁止编辑(与前端逻辑保持一致) + if ($task->status === 'running') { + throw new \RuntimeException("运行中的任务不允许编辑,请先停止任务: {$taskId}"); + } + + // timestamps会自动处理updated_at + + $result = $this->taskRepository->where('task_id', $taskId)->update($taskData); + + LoggerHelper::logBusiness('data_collection_task_updated', [ + 'task_id' => $taskId, + 'updated_fields' => array_keys($taskData), + ]); + + return $result > 0; + } + + /** + * 删除任务 + * + * 如果任务正在运行或已暂停,会先停止任务再删除 + * + * @param string $taskId 任务ID + * @return bool 是否删除成功 + */ + public function deleteTask(string $taskId): bool + { + // 使用where查询,因为主键是task_id而不是_id + $task = $this->taskRepository->where('task_id', $taskId)->first(); + + if (!$task) { + throw new \InvalidArgumentException("任务不存在: {$taskId}"); + } + + // 如果任务正在运行或已暂停,先停止 + if (in_array($task->status, ['running', 'paused'])) { + $this->stopTask($taskId); + } + + $result = $this->taskRepository->where('task_id', $taskId)->delete(); + + LoggerHelper::logBusiness('data_collection_task_deleted', [ + 'task_id' => $taskId, + 'previous_status' => $task->status, + ]); + + return $result > 0; + } + + /** + * 启动任务 + * + * 允许从以下状态启动: + * - pending (待启动) -> running + * - paused (已暂停) -> running (恢复) + * - stopped (已停止) -> running (重新启动) + * - completed (已完成) -> running (重新启动) + * - error (错误) -> running (重新启动) + * + * @param string $taskId 任务ID + * @return bool 是否启动成功 + */ + public function startTask(string $taskId): bool + { + // 使用where查询,因为主键是task_id而不是_id + $task = $this->taskRepository->where('task_id', $taskId)->first(); + + if (!$task) { + throw new \InvalidArgumentException("任务不存在: {$taskId}"); + } + + // 只允许从特定状态启动 + $allowedStatuses = ['pending', 'paused', 'stopped', 'completed', 'error']; + if (!in_array($task->status, $allowedStatuses)) { + if ($task->status === 'running') { + throw new \RuntimeException("任务已在运行中: {$taskId}"); + } + throw new \RuntimeException("任务当前状态不允许启动: {$taskId} (当前状态: {$task->status})"); + } + + // 如果是从 paused, stopped, completed, error 状态启动(重新启动),需要重置进度 + $progress = $task->progress ?? []; + if (in_array($task->status, ['paused', 'stopped', 'completed', 'error'])) { + // 重新启动时,重置进度(保留总数为0,表示重新开始) + $progress = [ + 'status' => 'running', + 'processed_count' => 0, + 'success_count' => 0, + 'error_count' => 0, + 'total_count' => 0, // 总数量会在采集开始时设置 + 'percentage' => 0, + 'start_time' => new \MongoDB\BSON\UTCDateTime(time() * 1000), + 'end_time' => null, + 'last_sync_time' => null, + ]; + } else { + // 从 pending 状态启动,初始化进度 + $progress['status'] = 'running'; + $progress['start_time'] = new \MongoDB\BSON\UTCDateTime(time() * 1000); + } + + $this->taskRepository->where('task_id', $taskId)->update([ + 'status' => 'running', + 'progress' => $progress, + ]); + + // 清除之前的暂停和停止标志(如果存在) + RedisHelper::del("data_collection_task:{$taskId}:pause"); + RedisHelper::del("data_collection_task:{$taskId}:stop"); + + // 设置Redis标志,通知调度器启动任务 + RedisHelper::set("data_collection_task:{$taskId}:start", '1', 3600); + + LoggerHelper::logBusiness('data_collection_task_started', [ + 'task_id' => $taskId, + 'previous_status' => $task->status, + ]); + + return true; + } + + /** + * 暂停任务 + * + * @param string $taskId 任务ID + * @return bool 是否暂停成功 + */ + public function pauseTask(string $taskId): bool + { + // 使用where查询,因为主键是task_id而不是_id + $task = $this->taskRepository->where('task_id', $taskId)->first(); + + if (!$task) { + throw new \InvalidArgumentException("任务不存在: {$taskId}"); + } + + if ($task->status !== 'running') { + throw new \RuntimeException("任务未在运行中: {$taskId}"); + } + + // 更新任务状态 + // 注意:需要使用完整的数组来更新嵌套字段,timestamps会自动处理updated_at + $progress = $task->progress ?? []; + $progress['status'] = 'paused'; + + $this->taskRepository->where('task_id', $taskId)->update([ + 'status' => 'paused', + 'progress' => $progress, + ]); + + // 设置Redis标志,通知调度器暂停任务 + RedisHelper::set("data_collection_task:{$taskId}:pause", '1', 3600); + + LoggerHelper::logBusiness('data_collection_task_paused', [ + 'task_id' => $taskId, + ]); + + return true; + } + + /** + * 停止任务 + * + * 只允许从以下状态停止: + * - running (运行中) -> stopped + * - paused (已暂停) -> stopped + * + * @param string $taskId 任务ID + * @return bool 是否停止成功 + */ + public function stopTask(string $taskId): bool + { + // 使用where查询,因为主键是task_id而不是_id + $task = $this->taskRepository->where('task_id', $taskId)->first(); + + if (!$task) { + throw new \InvalidArgumentException("任务不存在: {$taskId}"); + } + + // 只允许从 running 或 paused 状态停止 + if (!in_array($task->status, ['running', 'paused'])) { + throw new \RuntimeException("任务当前状态不允许停止: {$taskId} (当前状态: {$task->status})"); + } + + // 停止任务时,保持当前进度,不重置(只更新状态) + $currentProgress = $task->progress ?? []; + $progress = [ + 'status' => 'idle', // idle, running, paused, completed, error + 'processed_count' => $currentProgress['processed_count'] ?? 0, + 'success_count' => $currentProgress['success_count'] ?? 0, + 'error_count' => $currentProgress['error_count'] ?? 0, + 'total_count' => $currentProgress['total_count'] ?? 0, + 'percentage' => $currentProgress['percentage'] ?? 0, // 保持当前进度百分比 + 'start_time' => $currentProgress['start_time'] ?? null, + 'end_time' => new \MongoDB\BSON\UTCDateTime(time() * 1000), // 记录停止时间 + 'last_sync_time' => $currentProgress['last_sync_time'] ?? null, + ]; + + $this->taskRepository->where('task_id', $taskId)->update([ + 'status' => 'stopped', + 'progress' => $progress, + ]); + + // 设置Redis标志,通知调度器停止任务 + RedisHelper::set("data_collection_task:{$taskId}:stop", '1', 3600); + + // 如果任务之前是 paused,也需要清除暂停标志 + if ($task->status === 'paused') { + RedisHelper::del("data_collection_task:{$taskId}:pause"); + } + + LoggerHelper::logBusiness('data_collection_task_stopped', [ + 'task_id' => $taskId, + 'previous_status' => $task->status, + 'progress_reset' => true, + ]); + + return true; + } + + /** + * 获取任务列表 + * + * @param array $filters 过滤条件 + * @param int $page 页码 + * @param int $pageSize 每页数量 + * @return array 任务列表 + */ + public function getTaskList(array $filters = [], int $page = 1, int $pageSize = 20): array + { + $query = $this->taskRepository->query(); + + // 应用过滤条件(只处理非空值,如果筛选条件为空则返回所有任务) + if (!empty($filters['status']) && $filters['status'] !== '') { + $query->where('status', $filters['status']); + } + if (!empty($filters['data_source_id']) && $filters['data_source_id'] !== '') { + $query->where('data_source_id', $filters['data_source_id']); + } + if (!empty($filters['name']) && $filters['name'] !== '') { + // MongoDB 使用正则表达式进行模糊查询 + $namePattern = preg_quote($filters['name'], '/'); + $query->where('name', 'regex', "/{$namePattern}/i"); + } + + // 分页 + $total = $query->count(); + $taskModels = $query->orderBy('created_at', 'desc') + ->skip(($page - 1) * $pageSize) + ->take($pageSize) + ->get(); + + // 手动转换为数组,避免 cast 机制对数组字段的错误处理 + $tasks = []; + foreach ($taskModels as $model) { + $task = $model->getAttributes(); + // 使用统一的日期字段处理方法 + $task = $this->normalizeDateFields($task); + $tasks[] = $task; + } + + return [ + 'tasks' => $tasks, + 'total' => $total, + 'page' => $page, + 'page_size' => $pageSize, + 'total_pages' => ceil($total / $pageSize), + ]; + } + + /** + * 获取任务详情 + * + * @param string $taskId 任务ID + * @return array|null 任务详情 + */ + public function getTask(string $taskId): ?array + { + // 使用where查询,因为主键是task_id而不是_id + $task = $this->taskRepository->where('task_id', $taskId)->first(); + + if (!$task) { + return null; + } + + // 手动转换为数组,避免 cast 机制对数组字段的错误处理 + $taskArray = $task->getAttributes(); + // 使用统一的日期字段处理方法 + $taskArray = $this->normalizeDateFields($taskArray); + + return $taskArray; + } + + /** + * 更新任务进度 + * + * @param string $taskId 任务ID + * @param array $progress 进度信息 + * @return bool 是否更新成功 + */ + public function updateProgress(string $taskId, array $progress): bool + { + $updateData = [ + 'progress' => $progress, + ]; + + // 如果进度包含统计信息,也更新统计 + // 注意:这里的统计应该是累加的,但进度字段(processed_count等)应该直接设置 + if (isset($progress['success_count']) || isset($progress['error_count'])) { + // 使用where查询,因为主键是task_id而不是_id + $task = $this->taskRepository->where('task_id', $taskId)->first(); + if ($task) { + $statistics = $task->statistics ?? []; + // 统计信息使用增量更新(累加本次运行的数据) + // 但这里需要判断是增量还是绝对值,如果是绝对值则应该直接设置 + // 由于进度更新传入的是绝对值,所以这里应该直接使用最新值而不是累加 + if (isset($progress['processed_count'])) { + $statistics['total_processed'] = $progress['processed_count']; + } + if (isset($progress['success_count'])) { + $statistics['total_success'] = $progress['success_count']; + } + if (isset($progress['error_count'])) { + $statistics['total_error'] = $progress['error_count']; + } + $statistics['last_run_time'] = new \MongoDB\BSON\UTCDateTime(time() * 1000); + + $updateData['statistics'] = $statistics; + } + } + + // 使用 where()->update() 更新文档 + // 注意:MongoDB Laravel Eloquent 的 update() 返回匹配的文档数量(通常是1或0) + $result = $this->taskRepository->where('task_id', $taskId)->update($updateData); + + // 添加日志以便调试 + if ($result === false || $result === 0) { + \Workerman\Worker::safeEcho("[DataCollectionTaskService] ⚠️ 更新进度失败: task_id={$taskId}, result={$result}\n"); + } else { + \Workerman\Worker::safeEcho("[DataCollectionTaskService] ✅ 更新进度成功: task_id={$taskId}, 匹配文档数={$result}\n"); + } + + return $result > 0; + } + + /** + * 统一处理日期字段,转换为 ISO 8601 字符串格式 + * + * @param array $task 任务数据 + * @return array 处理后的任务数据 + */ + private function normalizeDateFields(array $task): array + { + foreach (['created_at', 'updated_at'] as $dateField) { + if (isset($task[$dateField])) { + if ($task[$dateField] instanceof \MongoDB\BSON\UTCDateTime) { + $task[$dateField] = $task[$dateField]->toDateTime()->format('Y-m-d\TH:i:s.000\Z'); + } elseif ($task[$dateField] instanceof \DateTime || $task[$dateField] instanceof \DateTimeInterface) { + $task[$dateField] = $task[$dateField]->format('Y-m-d\TH:i:s.000\Z'); + } elseif (is_array($task[$dateField]) && isset($task[$dateField]['$date'])) { + // 处理 JSON 编码后的日期格式 + $dateValue = $task[$dateField]['$date']; + if (is_numeric($dateValue)) { + // 如果是数字,假设是毫秒时间戳 + $timestamp = $dateValue / 1000; + $task[$dateField] = date('Y-m-d\TH:i:s.000\Z', (int)$timestamp); + } elseif (is_array($dateValue) && isset($dateValue['$numberLong'])) { + // MongoDB 扩展 JSON 格式:{"$date": {"$numberLong": "1640000000000"}} + $timestamp = intval($dateValue['$numberLong']) / 1000; + $task[$dateField] = date('Y-m-d\TH:i:s.000\Z', (int)$timestamp); + } else { + // 其他格式,尝试解析或保持原样 + $task[$dateField] = is_string($dateValue) ? $dateValue : json_encode($dateValue); + } + } + } + } + return $task; + } + + /** + * 获取所有运行中的任务 + * + * @return array> 运行中的任务列表 + */ + public function getRunningTasks(): array + { + // 使用原生 MongoDB 查询,避免 Model 的 cast 机制导致数组字段被错误处理 + $dbConfig = config('database.connections.mongodb'); + + // 使用 MongoDBHelper 创建客户端(统一DSN构建逻辑) + $client = \app\utils\MongoDBHelper::createClient([ + 'host' => parse_url($dbConfig['dsn'], PHP_URL_HOST) ?? '192.168.1.106', + 'port' => parse_url($dbConfig['dsn'], PHP_URL_PORT) ?? 27017, + 'username' => $dbConfig['username'] ?? '', + 'password' => $dbConfig['password'] ?? '', + 'auth_source' => $dbConfig['options']['authSource'] ?? 'admin', + ], array_filter($dbConfig['options'] ?? [], function ($value) { + return $value !== '' && $value !== null; + })); + + $database = $client->selectDatabase($dbConfig['database']); + $collection = $database->selectCollection('data_collection_tasks'); + + // 查询所有运行中的任务 + $cursor = $collection->find(['status' => 'running']); + + $tasks = []; + foreach ($cursor as $document) { + // MongoDB BSONDocument 需要转换为数组 + if ($document instanceof \MongoDB\Model\BSONDocument) { + $task = json_decode(json_encode($document), true); + } elseif (is_array($document)) { + $task = $document; + } else { + // 其他类型,尝试转换为数组 + $task = (array)$document; + } + + // 处理 MongoDB 的 _id 字段 + if (isset($task['_id'])) { + if (is_object($task['_id'])) { + $task['_id'] = (string)$task['_id']; + } + } + // 使用统一的日期字段处理方法 + $task = $this->normalizeDateFields($task); + $tasks[] = $task; + } + + return $tasks; + } +} + diff --git a/app/service/DataSource/Adapter/MongoDBAdapter.php b/app/service/DataSource/Adapter/MongoDBAdapter.php new file mode 100644 index 0000000..b4f5085 --- /dev/null +++ b/app/service/DataSource/Adapter/MongoDBAdapter.php @@ -0,0 +1,309 @@ + $config 数据源配置 + * @return bool 是否连接成功 + */ + public function connect(array $config): bool + { + try { + $host = $config['host'] ?? '127.0.0.1'; + $port = (int)($config['port'] ?? 27017); + $this->databaseName = $config['database'] ?? ''; + $username = $config['username'] ?? ''; + $password = $config['password'] ?? ''; + $authSource = $config['auth_source'] ?? $this->databaseName; + + // 构建 DSN + $dsn = "mongodb://"; + if (!empty($username) && !empty($password)) { + $dsn .= urlencode($username) . ':' . urlencode($password) . '@'; + } + $dsn .= "{$host}:{$port}"; + if (!empty($this->databaseName)) { + $dsn .= "/{$this->databaseName}"; + } + if (!empty($authSource)) { + $dsn .= "?authSource=" . urlencode($authSource); + } + + // MongoDB 连接选项 + $options = []; + if (isset($config['options'])) { + $options = array_filter($config['options'], function ($value) { + return $value !== '' && $value !== null; + }); + } + + // 设置超时选项 + if (!isset($options['connectTimeoutMS'])) { + $options['connectTimeoutMS'] = ($config['timeout'] ?? 10) * 1000; + } + if (!isset($options['socketTimeoutMS'])) { + $options['socketTimeoutMS'] = ($config['timeout'] ?? 10) * 1000; + } + + $this->client = new Client($dsn, $options); + + // 选择数据库 + if (!empty($this->databaseName)) { + $this->database = $this->client->selectDatabase($this->databaseName); + } + + // 测试连接 + $this->client->getManager()->selectServer(); + + LoggerHelper::logBusiness('mongodb_adapter_connected', [ + 'host' => $host, + 'port' => $port, + 'database' => $this->databaseName, + ]); + + return true; + } catch (MongoDBException $e) { + LoggerHelper::logError($e, [ + 'component' => 'MongoDBAdapter', + 'action' => 'connect', + 'config' => array_merge($config, ['password' => '***']), // 隐藏密码 + ]); + return false; + } + } + + /** + * 关闭数据库连接 + * + * @return void + */ + public function disconnect(): void + { + if ($this->client !== null) { + $this->client = null; + $this->database = null; + LoggerHelper::logBusiness('mongodb_adapter_disconnected', []); + } + } + + /** + * 测试连接是否有效 + * + * @return bool 连接是否有效 + */ + public function isConnected(): bool + { + if ($this->client === null) { + return false; + } + + try { + // 执行 ping 命令测试连接 + $adminDb = $this->client->selectDatabase('admin'); + $adminDb->command(['ping' => 1]); + return true; + } catch (MongoDBException $e) { + LoggerHelper::logError($e, [ + 'component' => 'MongoDBAdapter', + 'action' => 'isConnected', + ]); + return false; + } + } + + /** + * 执行查询(返回多条记录) + * + * 注意:对于 MongoDB,$sql 参数表示集合名称,$params 是一个包含 'filter' 和 'options' 的数组 + * + * @param string $sql 集合名称(MongoDB 中相当于表名) + * @param array $params 查询参数,格式:['filter' => [...], 'options' => [...]] + * @return array> 查询结果数组 + */ + public function query(string $sql, array $params = []): array + { + if ($this->database === null) { + throw new \RuntimeException('数据库连接未建立或未选择数据库'); + } + + try { + $collection = $sql; // $sql 参数在 MongoDB 中表示集合名 + $filter = $params['filter'] ?? []; + $options = $params['options'] ?? []; + + $cursor = $this->database->selectCollection($collection)->find($filter, $options); + $results = []; + + foreach ($cursor as $document) { + $results[] = $this->convertMongoDocumentToArray($document); + } + + LoggerHelper::logBusiness('mongodb_query_executed', [ + 'collection' => $collection, + 'filter' => $filter, + 'result_count' => count($results), + ]); + + return $results; + } catch (MongoDBException $e) { + LoggerHelper::logError($e, [ + 'component' => 'MongoDBAdapter', + 'action' => 'query', + 'collection' => $sql, + 'params' => $params, + ]); + throw $e; + } + } + + /** + * 执行查询(返回单条记录) + * + * 注意:对于 MongoDB,$sql 参数表示集合名称,$params 是一个包含 'filter' 和 'options' 的数组 + * + * @param string $sql 集合名称 + * @param array $params 查询参数,格式:['filter' => [...], 'options' => [...]] + * @return array|null 查询结果(单条记录)或 null + */ + public function queryOne(string $sql, array $params = []): ?array + { + if ($this->database === null) { + throw new \RuntimeException('数据库连接未建立或未选择数据库'); + } + + try { + $collection = $sql; // $sql 参数在 MongoDB 中表示集合名 + $filter = $params['filter'] ?? []; + $options = $params['options'] ?? []; + + $document = $this->database->selectCollection($collection)->findOne($filter, $options); + + if ($document === null) { + return null; + } + + LoggerHelper::logBusiness('mongodb_query_one_executed', [ + 'collection' => $collection, + 'filter' => $filter, + 'has_result' => true, + ]); + + return $this->convertMongoDocumentToArray($document); + } catch (MongoDBException $e) { + LoggerHelper::logError($e, [ + 'component' => 'MongoDBAdapter', + 'action' => 'queryOne', + 'collection' => $sql, + 'params' => $params, + ]); + throw $e; + } + } + + /** + * 批量查询(分页查询,用于大数据量场景) + * + * 注意:对于 MongoDB,$sql 参数表示集合名称,$params 是一个包含 'filter' 和 'options' 的数组 + * + * @param string $sql 集合名称 + * @param array $params 查询参数,格式:['filter' => [...], 'options' => [...]] + * @param int $offset 偏移量 + * @param int $limit 每页数量 + * @return array> 查询结果数组 + */ + public function queryBatch(string $sql, array $params = [], int $offset = 0, int $limit = 1000): array + { + if ($this->database === null) { + throw new \RuntimeException('数据库连接未建立或未选择数据库'); + } + + try { + $collection = $sql; // $sql 参数在 MongoDB 中表示集合名 + $filter = $params['filter'] ?? []; + $options = $params['options'] ?? []; + + // 设置分页选项 + $options['skip'] = $offset; + $options['limit'] = $limit; + + $cursor = $this->database->selectCollection($collection)->find($filter, $options); + $results = []; + + foreach ($cursor as $document) { + $results[] = $this->convertMongoDocumentToArray($document); + } + + LoggerHelper::logBusiness('mongodb_query_batch_executed', [ + 'collection' => $collection, + 'offset' => $offset, + 'limit' => $limit, + 'result_count' => count($results), + ]); + + return $results; + } catch (MongoDBException $e) { + LoggerHelper::logError($e, [ + 'component' => 'MongoDBAdapter', + 'action' => 'queryBatch', + 'collection' => $sql, + 'params' => $params, + 'offset' => $offset, + 'limit' => $limit, + ]); + throw $e; + } + } + + /** + * 获取数据源类型 + * + * @return string 数据源类型 + */ + public function getType(): string + { + return $this->type; + } + + /** + * 将 MongoDB 文档转换为数组 + * + * @param mixed $document MongoDB 文档对象 + * @return array 数组格式的数据 + */ + private function convertMongoDocumentToArray($document): array + { + if (is_array($document)) { + return $document; + } + + // MongoDB\BSON\Document 或 MongoDB\Model\BSONDocument + if (method_exists($document, 'toArray')) { + return $document->toArray(); + } + + // 转换为数组 + return json_decode(json_encode($document), true) ?? []; + } +} + diff --git a/app/service/DataSource/Adapter/MySQLAdapter.php b/app/service/DataSource/Adapter/MySQLAdapter.php new file mode 100644 index 0000000..966ef98 --- /dev/null +++ b/app/service/DataSource/Adapter/MySQLAdapter.php @@ -0,0 +1,234 @@ + $config 数据源配置 + * @return bool 是否连接成功 + */ + public function connect(array $config): bool + { + try { + $host = $config['host'] ?? '127.0.0.1'; + $port = $config['port'] ?? 3306; + $database = $config['database'] ?? ''; + $username = $config['username'] ?? ''; + $password = $config['password'] ?? ''; + $charset = $config['charset'] ?? 'utf8mb4'; + + // 构建 DSN + $dsn = "mysql:host={$host};port={$port};dbname={$database};charset={$charset}"; + + // PDO 选项 + $options = [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, // 禁用预处理语句模拟 + PDO::ATTR_PERSISTENT => $config['persistent'] ?? false, // 是否持久连接 + PDO::ATTR_TIMEOUT => $config['timeout'] ?? 10, // 连接超时 + ]; + + $this->connection = new PDO($dsn, $username, $password, $options); + + LoggerHelper::logBusiness('mysql_adapter_connected', [ + 'host' => $host, + 'port' => $port, + 'database' => $database, + ]); + + return true; + } catch (PDOException $e) { + LoggerHelper::logError($e, [ + 'component' => 'MySQLAdapter', + 'action' => 'connect', + 'config' => array_merge($config, ['password' => '***']), // 隐藏密码 + ]); + return false; + } + } + + /** + * 关闭数据库连接 + * + * @return void + */ + public function disconnect(): void + { + if ($this->connection !== null) { + $this->connection = null; + LoggerHelper::logBusiness('mysql_adapter_disconnected', []); + } + } + + /** + * 测试连接是否有效 + * + * @return bool 连接是否有效 + */ + public function isConnected(): bool + { + if ($this->connection === null) { + return false; + } + + try { + // 执行简单查询测试连接 + $this->connection->query('SELECT 1'); + return true; + } catch (PDOException $e) { + LoggerHelper::logError($e, [ + 'component' => 'MySQLAdapter', + 'action' => 'isConnected', + ]); + return false; + } + } + + /** + * 执行查询(返回多条记录) + * + * @param string $sql SQL 查询语句 + * @param array $params 查询参数(绑定参数) + * @return array> 查询结果数组 + */ + public function query(string $sql, array $params = []): array + { + if ($this->connection === null) { + throw new \RuntimeException('数据库连接未建立'); + } + + try { + $stmt = $this->connection->prepare($sql); + $stmt->execute($params); + $results = $stmt->fetchAll(PDO::FETCH_ASSOC); + + LoggerHelper::logBusiness('mysql_query_executed', [ + 'sql' => $sql, + 'params_count' => count($params), + 'result_count' => count($results), + ]); + + return $results; + } catch (PDOException $e) { + LoggerHelper::logError($e, [ + 'component' => 'MySQLAdapter', + 'action' => 'query', + 'sql' => $sql, + 'params' => $params, + ]); + throw $e; + } + } + + /** + * 执行查询(返回单条记录) + * + * @param string $sql SQL 查询语句 + * @param array $params 查询参数 + * @return array|null 查询结果(单条记录)或 null + */ + public function queryOne(string $sql, array $params = []): ?array + { + if ($this->connection === null) { + throw new \RuntimeException('数据库连接未建立'); + } + + try { + $stmt = $this->connection->prepare($sql); + $stmt->execute($params); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + + LoggerHelper::logBusiness('mysql_query_one_executed', [ + 'sql' => $sql, + 'params_count' => count($params), + 'has_result' => $result !== false, + ]); + + return $result !== false ? $result : null; + } catch (PDOException $e) { + LoggerHelper::logError($e, [ + 'component' => 'MySQLAdapter', + 'action' => 'queryOne', + 'sql' => $sql, + 'params' => $params, + ]); + throw $e; + } + } + + /** + * 批量查询(分页查询,用于大数据量场景) + * + * @param string $sql SQL 查询语句(需要包含 LIMIT 和 OFFSET,或由适配器自动添加) + * @param array $params 查询参数 + * @param int $offset 偏移量 + * @param int $limit 每页数量 + * @return array> 查询结果数组 + */ + public function queryBatch(string $sql, array $params = [], int $offset = 0, int $limit = 1000): array + { + if ($this->connection === null) { + throw new \RuntimeException('数据库连接未建立'); + } + + try { + // 如果 SQL 中已包含 LIMIT,则直接使用;否则自动添加 + if (stripos($sql, 'LIMIT') === false) { + $sql .= " LIMIT {$limit} OFFSET {$offset}"; + } + + $stmt = $this->connection->prepare($sql); + $stmt->execute($params); + $results = $stmt->fetchAll(PDO::FETCH_ASSOC); + + LoggerHelper::logBusiness('mysql_query_batch_executed', [ + 'sql' => $sql, + 'offset' => $offset, + 'limit' => $limit, + 'result_count' => count($results), + ]); + + return $results; + } catch (PDOException $e) { + LoggerHelper::logError($e, [ + 'component' => 'MySQLAdapter', + 'action' => 'queryBatch', + 'sql' => $sql, + 'params' => $params, + 'offset' => $offset, + 'limit' => $limit, + ]); + throw $e; + } + } + + /** + * 获取数据源类型 + * + * @return string 数据源类型 + */ + public function getType(): string + { + return $this->type; + } +} + diff --git a/app/service/DataSource/DataSourceAdapterFactory.php b/app/service/DataSource/DataSourceAdapterFactory.php new file mode 100644 index 0000000..0935f65 --- /dev/null +++ b/app/service/DataSource/DataSourceAdapterFactory.php @@ -0,0 +1,116 @@ + + */ + private static array $instances = []; + + /** + * 创建数据源适配器 + * + * @param string $type 数据源类型(mysql、postgresql、mongodb 等) + * @param array $config 数据源配置 + * @return DataSourceAdapterInterface 适配器实例 + * @throws \InvalidArgumentException 不支持的数据源类型 + */ + public static function create(string $type, array $config): DataSourceAdapterInterface + { + // 生成缓存键(基于类型和配置) + $cacheKey = self::generateCacheKey($type, $config); + + // 如果已存在实例,直接返回 + if (isset(self::$instances[$cacheKey])) { + $adapter = self::$instances[$cacheKey]; + // 检查连接是否有效 + if ($adapter->isConnected()) { + return $adapter; + } + // 连接已断开,重新创建 + unset(self::$instances[$cacheKey]); + } + + // 根据类型创建适配器 + $adapter = match (strtolower($type)) { + 'mysql' => new MySQLAdapter(), + 'mongodb' => new MongoDBAdapter(), + // 'postgresql' => new PostgreSQLAdapter(), + default => throw new \InvalidArgumentException("不支持的数据源类型: {$type}"), + }; + + // 建立连接 + if (!$adapter->connect($config)) { + throw new \RuntimeException("无法连接到数据源: {$type}"); + } + + // 缓存实例 + self::$instances[$cacheKey] = $adapter; + + LoggerHelper::logBusiness('data_source_adapter_created', [ + 'type' => $type, + 'cache_key' => $cacheKey, + ]); + + return $adapter; + } + + /** + * 生成缓存键 + * + * @param string $type 数据源类型 + * @param array $config 数据源配置 + * @return string 缓存键 + */ + private static function generateCacheKey(string $type, array $config): string + { + // 基于类型、主机、端口、数据库名生成唯一键 + $host = $config['host'] ?? 'unknown'; + $port = $config['port'] ?? 'unknown'; + $database = $config['database'] ?? 'unknown'; + return md5("{$type}:{$host}:{$port}:{$database}"); + } + + /** + * 清除所有适配器实例(用于测试或重新连接) + * + * @return void + */ + public static function clearInstances(): void + { + foreach (self::$instances as $adapter) { + try { + $adapter->disconnect(); + } catch (\Throwable $e) { + LoggerHelper::logError($e, ['component' => 'DataSourceAdapterFactory', 'action' => 'clearInstances']); + } + } + self::$instances = []; + } + + /** + * 获取所有已创建的适配器实例 + * + * @return array + */ + public static function getInstances(): array + { + return self::$instances; + } +} + diff --git a/app/service/DataSource/DataSourceAdapterInterface.php b/app/service/DataSource/DataSourceAdapterInterface.php new file mode 100644 index 0000000..2d16058 --- /dev/null +++ b/app/service/DataSource/DataSourceAdapterInterface.php @@ -0,0 +1,73 @@ + $config 数据源配置 + * @return bool 是否连接成功 + */ + public function connect(array $config): bool; + + /** + * 关闭数据库连接 + * + * @return void + */ + public function disconnect(): void; + + /** + * 测试连接是否有效 + * + * @return bool 连接是否有效 + */ + public function isConnected(): bool; + + /** + * 执行查询(返回多条记录) + * + * @param string $sql SQL 查询语句(或 MongoDB 查询条件) + * @param array $params 查询参数(绑定参数或 MongoDB 查询选项) + * @return array> 查询结果数组 + */ + public function query(string $sql, array $params = []): array; + + /** + * 执行查询(返回单条记录) + * + * @param string $sql SQL 查询语句(或 MongoDB 查询条件) + * @param array $params 查询参数 + * @return array|null 查询结果(单条记录)或 null + */ + public function queryOne(string $sql, array $params = []): ?array; + + /** + * 批量查询(分页查询,用于大数据量场景) + * + * @param string $sql SQL 查询语句 + * @param array $params 查询参数 + * @param int $offset 偏移量 + * @param int $limit 每页数量 + * @return array> 查询结果数组 + */ + public function queryBatch(string $sql, array $params = [], int $offset = 0, int $limit = 1000): array; + + /** + * 获取数据源类型 + * + * @return string 数据源类型(mysql、postgresql、mongodb 等) + */ + public function getType(): string; +} + diff --git a/app/service/DataSource/PollingStrategyFactory.php b/app/service/DataSource/PollingStrategyFactory.php new file mode 100644 index 0000000..bb79b1e --- /dev/null +++ b/app/service/DataSource/PollingStrategyFactory.php @@ -0,0 +1,68 @@ + $strategyConfig 策略配置(字符串为策略类名,数组包含 class 和 config) + * @return PollingStrategyInterface 策略实例 + * @throws \InvalidArgumentException 无效的策略配置 + */ + public static function create(string|array $strategyConfig): PollingStrategyInterface + { + // 如果配置是字符串,则作为策略类名 + if (is_string($strategyConfig)) { + $className = $strategyConfig; + $strategyConfig = ['class' => $className]; + } + + // 获取策略类名 + $className = $strategyConfig['class'] ?? null; + if (!$className) { + // 如果没有指定策略,使用默认策略 + $className = DefaultConsumptionStrategy::class; + } + + // 验证类是否存在 + if (!class_exists($className)) { + throw new \InvalidArgumentException("策略类不存在: {$className}"); + } + + // 验证类是否实现了接口 + if (!is_subclass_of($className, PollingStrategyInterface::class)) { + throw new \InvalidArgumentException("策略类必须实现 PollingStrategyInterface: {$className}"); + } + + // 创建策略实例 + try { + $strategy = new $className(); + + LoggerHelper::logBusiness('polling_strategy_created', [ + 'class' => $className, + ]); + + return $strategy; + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'PollingStrategyFactory', + 'action' => 'create', + 'class' => $className, + ]); + throw new \RuntimeException("无法创建策略实例: {$className}", 0, $e); + } + } +} + diff --git a/app/service/DataSource/PollingStrategyInterface.php b/app/service/DataSource/PollingStrategyInterface.php new file mode 100644 index 0000000..fda2111 --- /dev/null +++ b/app/service/DataSource/PollingStrategyInterface.php @@ -0,0 +1,54 @@ + $config 数据源配置 + * @param array $lastSyncInfo 上次同步信息(包含 last_sync_time、last_sync_id 等) + * @return array> 查询结果数组(原始数据) + */ + public function poll( + DataSourceAdapterInterface $adapter, + array $config, + array $lastSyncInfo = [] + ): array; + + /** + * 数据转换 + * + * @param array> $rawData 原始数据 + * @param array $config 数据源配置 + * @return array> 转换后的数据(标准格式) + */ + public function transform(array $rawData, array $config): array; + + /** + * 数据验证 + * + * @param array $record 单条记录 + * @param array $config 数据源配置 + * @return bool 是否通过验证 + */ + public function validate(array $record, array $config): bool; + + /** + * 获取策略名称 + * + * @return string 策略名称 + */ + public function getName(): string; +} + diff --git a/app/service/DataSource/Strategy/DefaultConsumptionStrategy.php b/app/service/DataSource/Strategy/DefaultConsumptionStrategy.php new file mode 100644 index 0000000..dddf79c --- /dev/null +++ b/app/service/DataSource/Strategy/DefaultConsumptionStrategy.php @@ -0,0 +1,197 @@ + $config 数据源配置 + * @param array $lastSyncInfo 上次同步信息 + * @return array> 查询结果数组 + */ + public function poll( + DataSourceAdapterInterface $adapter, + array $config, + array $lastSyncInfo = [] + ): array { + // 从配置中获取表名和查询条件 + $tableName = $config['table'] ?? 'consumption_records'; + $lastSyncTime = $lastSyncInfo['last_sync_time'] ?? null; + $lastSyncId = $lastSyncInfo['last_sync_id'] ?? null; + + // 构建 SQL 查询(增量查询) + $sql = "SELECT * FROM `{$tableName}` WHERE 1=1"; + $params = []; + + // 如果有上次同步时间,只查询新增或更新的记录 + if ($lastSyncTime !== null) { + $sql .= " AND (`created_at` > :last_sync_time OR `updated_at` > :last_sync_time)"; + $params[':last_sync_time'] = $lastSyncTime; + } + + // 如果有上次同步ID,用于去重(可选) + if ($lastSyncId !== null) { + $sql .= " AND `id` > :last_sync_id"; + $params[':last_sync_id'] = $lastSyncId; + } + + // 按创建时间排序 + $sql .= " ORDER BY `created_at` ASC, `id` ASC"; + + // 执行查询(批量查询,每次最多1000条) + $limit = $config['batch_size'] ?? 1000; + $offset = 0; + $allResults = []; + + do { + $batchSql = $sql . " LIMIT {$limit} OFFSET {$offset}"; + $results = $adapter->queryBatch($batchSql, $params, $offset, $limit); + + if (empty($results)) { + break; + } + + $allResults = array_merge($allResults, $results); + $offset += $limit; + + // 防止无限循环(最多查询10万条) + if (count($allResults) >= 100000) { + LoggerHelper::logBusiness('polling_batch_limit_reached', [ + 'table' => $tableName, + 'count' => count($allResults), + ]); + break; + } + } while (count($results) === $limit); + + LoggerHelper::logBusiness('polling_query_completed', [ + 'table' => $tableName, + 'result_count' => count($allResults), + 'last_sync_time' => $lastSyncTime, + ]); + + return $allResults; + } + + /** + * 数据转换 + * + * @param array> $rawData 原始数据 + * @param array $config 数据源配置 + * @return array> 转换后的数据 + */ + public function transform(array $rawData, array $config): array + { + // 字段映射配置(从外部数据库字段映射到标准字段) + $fieldMapping = $config['field_mapping'] ?? [ + // 默认映射(如果外部数据库字段名与标准字段名一致,则无需映射) + 'id' => 'id', + 'user_id' => 'user_id', + 'amount' => 'amount', + 'store_id' => 'store_id', + 'product_id' => 'product_id', + 'consume_time' => 'consume_time', + 'created_at' => 'created_at', + ]; + + $transformedData = []; + + foreach ($rawData as $record) { + $transformed = []; + + // 应用字段映射 + foreach ($fieldMapping as $standardField => $sourceField) { + if (isset($record[$sourceField])) { + $transformed[$standardField] = $record[$sourceField]; + } + } + + // 确保必要字段存在 + if (!empty($transformed)) { + $transformedData[] = $transformed; + } + } + + LoggerHelper::logBusiness('polling_transform_completed', [ + 'input_count' => count($rawData), + 'output_count' => count($transformedData), + ]); + + return $transformedData; + } + + /** + * 数据验证 + * + * @param array $record 单条记录 + * @param array $config 数据源配置 + * @return bool 是否通过验证 + */ + public function validate(array $record, array $config): bool + { + // 必填字段验证 + $requiredFields = $config['required_fields'] ?? ['user_id', 'amount', 'consume_time']; + + foreach ($requiredFields as $field) { + if (!isset($record[$field]) || $record[$field] === null || $record[$field] === '') { + LoggerHelper::logBusiness('polling_validation_failed', [ + 'reason' => "缺少必填字段: {$field}", + 'record' => $record, + ]); + return false; + } + } + + // 金额验证(必须为正数) + if (isset($record['amount'])) { + $amount = (float)$record['amount']; + if ($amount <= 0) { + LoggerHelper::logBusiness('polling_validation_failed', [ + 'reason' => '金额必须大于0', + 'amount' => $amount, + ]); + return false; + } + } + + // 时间格式验证(可选) + if (isset($record['consume_time'])) { + $time = strtotime($record['consume_time']); + if ($time === false) { + LoggerHelper::logBusiness('polling_validation_failed', [ + 'reason' => '时间格式无效', + 'consume_time' => $record['consume_time'], + ]); + return false; + } + } + + return true; + } + + /** + * 获取策略名称 + * + * @return string 策略名称 + */ + public function getName(): string + { + return 'default_consumption'; + } +} + diff --git a/app/service/DataSource/Strategy/MongoDBConsumptionStrategy.php b/app/service/DataSource/Strategy/MongoDBConsumptionStrategy.php new file mode 100644 index 0000000..d6cc495 --- /dev/null +++ b/app/service/DataSource/Strategy/MongoDBConsumptionStrategy.php @@ -0,0 +1,225 @@ + $config 数据源配置 + * @param array $lastSyncInfo 上次同步信息 + * @return array> 查询结果数组 + */ + public function poll( + DataSourceAdapterInterface $adapter, + array $config, + array $lastSyncInfo = [] + ): array { + // 从配置中获取集合名和查询条件 + $collectionName = $config['collection'] ?? 'consumption_records'; + $lastSyncTime = $lastSyncInfo['last_sync_time'] ?? null; + $lastSyncId = $lastSyncInfo['last_sync_id'] ?? null; + + // 构建 MongoDB 查询过滤器 + $filter = []; + + // 如果有上次同步时间,只查询新增或更新的记录 + if ($lastSyncTime !== null) { + $lastSyncTimestamp = is_numeric($lastSyncTime) ? (int)$lastSyncTime : strtotime($lastSyncTime); + $lastSyncDate = new \MongoDB\BSON\UTCDateTime($lastSyncTimestamp * 1000); + + $filter['$or'] = [ + ['created_at' => ['$gt' => $lastSyncDate]], + ['updated_at' => ['$gt' => $lastSyncDate]], + ]; + } + + // 如果有上次同步ID,用于去重(可选) + if ($lastSyncId !== null) { + $filter['_id'] = ['$gt' => $lastSyncId]; + } + + // 查询选项 + $options = [ + 'sort' => ['created_at' => 1, '_id' => 1], // 按创建时间和ID排序 + ]; + + // 执行查询(批量查询,每次最多1000条) + $limit = $config['batch_size'] ?? 1000; + $offset = 0; + $allResults = []; + + do { + // MongoDB 适配器的 queryBatch 方法签名:queryBatch(string $sql, array $params = [], int $offset = 0, int $limit = 1000) + // 对于 MongoDB,$sql 是集合名,$params 包含 'filter' 和 'options' + $results = $adapter->queryBatch($collectionName, [ + 'filter' => $filter, + 'options' => $options, + ], $offset, $limit); + + if (empty($results)) { + break; + } + + $allResults = array_merge($allResults, $results); + $offset += $limit; + + // 防止无限循环(最多查询10万条) + if (count($allResults) >= 100000) { + LoggerHelper::logBusiness('polling_batch_limit_reached', [ + 'collection' => $collectionName, + 'count' => count($allResults), + ]); + break; + } + } while (count($results) === $limit); + + LoggerHelper::logBusiness('polling_query_completed', [ + 'collection' => $collectionName, + 'result_count' => count($allResults), + 'last_sync_time' => $lastSyncTime, + ]); + + return $allResults; + } + + /** + * 数据转换 + * + * @param array> $rawData 原始数据 + * @param array $config 数据源配置 + * @return array> 转换后的数据 + */ + public function transform(array $rawData, array $config): array + { + // 字段映射配置(从外部数据库字段映射到标准字段) + $fieldMapping = $config['field_mapping'] ?? [ + // 默认映射(MongoDB 使用 _id,需要转换为 id) + '_id' => 'id', + 'user_id' => 'user_id', + 'amount' => 'amount', + 'store_id' => 'store_id', + 'product_id' => 'product_id', + 'consume_time' => 'consume_time', + 'created_at' => 'created_at', + ]; + + $transformedData = []; + + foreach ($rawData as $record) { + $transformed = []; + + // 处理 MongoDB 的 _id 字段(转换为字符串) + if (isset($record['_id'])) { + if (is_object($record['_id']) && method_exists($record['_id'], '__toString')) { + $record['id'] = (string)$record['_id']; + } else { + $record['id'] = (string)$record['_id']; + } + } + + // 处理 MongoDB 的日期字段(UTCDateTime 转换为字符串) + foreach (['created_at', 'updated_at', 'consume_time'] as $dateField) { + if (isset($record[$dateField])) { + if (is_object($record[$dateField]) && method_exists($record[$dateField], 'toDateTime')) { + $record[$dateField] = $record[$dateField]->toDateTime()->format('Y-m-d H:i:s'); + } elseif (is_object($record[$dateField]) && method_exists($record[$dateField], '__toString')) { + $record[$dateField] = (string)$record[$dateField]; + } + } + } + + // 应用字段映射 + foreach ($fieldMapping as $standardField => $sourceField) { + if (isset($record[$sourceField])) { + $transformed[$standardField] = $record[$sourceField]; + } + } + + // 确保必要字段存在 + if (!empty($transformed)) { + $transformedData[] = $transformed; + } + } + + LoggerHelper::logBusiness('polling_transform_completed', [ + 'input_count' => count($rawData), + 'output_count' => count($transformedData), + ]); + + return $transformedData; + } + + /** + * 数据验证 + * + * @param array $record 单条记录 + * @param array $config 数据源配置 + * @return bool 是否通过验证 + */ + public function validate(array $record, array $config): bool + { + // 必填字段验证 + $requiredFields = $config['required_fields'] ?? ['user_id', 'amount', 'consume_time']; + + foreach ($requiredFields as $field) { + if (!isset($record[$field]) || $record[$field] === null || $record[$field] === '') { + LoggerHelper::logBusiness('polling_validation_failed', [ + 'reason' => "缺少必填字段: {$field}", + 'record' => $record, + ]); + return false; + } + } + + // 金额验证(必须为正数) + if (isset($record['amount'])) { + $amount = (float)$record['amount']; + if ($amount <= 0) { + LoggerHelper::logBusiness('polling_validation_failed', [ + 'reason' => '金额必须大于0', + 'amount' => $amount, + ]); + return false; + } + } + + // 时间格式验证(可选) + if (isset($record['consume_time'])) { + $time = strtotime($record['consume_time']); + if ($time === false) { + LoggerHelper::logBusiness('polling_validation_failed', [ + 'reason' => '时间格式无效', + 'consume_time' => $record['consume_time'], + ]); + return false; + } + } + + return true; + } + + /** + * 获取策略名称 + * + * @return string 策略名称 + */ + public function getName(): string + { + return 'mongodb_consumption'; + } +} + diff --git a/app/service/DataSourceService.php b/app/service/DataSourceService.php new file mode 100644 index 0000000..b27396e --- /dev/null +++ b/app/service/DataSourceService.php @@ -0,0 +1,498 @@ + $data + * @return DataSourceRepository + * @throws \Exception + */ + public function createDataSource(array $data): DataSourceRepository + { + // 生成ID + if (empty($data['data_source_id'])) { + $data['data_source_id'] = UuidGenerator::uuid4()->toString(); + } + + // 验证必填字段 + $requiredFields = ['name', 'type', 'host', 'port', 'database']; + foreach ($requiredFields as $field) { + if (empty($data[$field])) { + throw new \InvalidArgumentException("缺少必填字段: {$field}"); + } + } + + // 验证类型 + $allowedTypes = ['mongodb', 'mysql', 'postgresql']; + if (!in_array(strtolower($data['type']), $allowedTypes)) { + throw new \InvalidArgumentException("不支持的数据源类型: {$data['type']}"); + } + + // 验证ID唯一性 + $existing = $this->repository->newQuery() + ->where('data_source_id', $data['data_source_id']) + ->first(); + + if ($existing) { + throw new \InvalidArgumentException("数据源ID已存在: {$data['data_source_id']}"); + } + + // 验证名称唯一性 + $existingByName = $this->repository->newQuery() + ->where('name', $data['name']) + ->first(); + + if ($existingByName) { + throw new \InvalidArgumentException("数据源名称已存在: {$data['name']}"); + } + + // 设置默认值 + $data['status'] = $data['status'] ?? 1; // 1:启用, 0:禁用 + $data['options'] = $data['options'] ?? []; + $data['is_tag_engine'] = $data['is_tag_engine'] ?? false; // 默认不是标签引擎数据库 + + // 创建数据源 + $dataSource = new DataSourceRepository($data); + $dataSource->save(); + + // 如果设置为标签引擎数据库,自动将其他数据源设置为 false(确保只有一个) + if (!empty($data['is_tag_engine'])) { + // 将所有其他数据源的 is_tag_engine 设置为 false + $this->repository->newQuery() + ->where('data_source_id', '!=', $dataSource->data_source_id) + ->update(['is_tag_engine' => false]); + + LoggerHelper::logBusiness('tag_engine_set', [ + 'data_source_id' => $dataSource->data_source_id, + 'action' => 'create', + ]); + } + + LoggerHelper::logBusiness('data_source_created', [ + 'data_source_id' => $dataSource->data_source_id, + 'name' => $dataSource->name, + 'type' => $dataSource->type, + ]); + + return $dataSource; + } + + /** + * 更新数据源 + * + * @param string $dataSourceId + * @param array $data + * @return bool + */ + public function updateDataSource(string $dataSourceId, array $data): bool + { + $dataSource = $this->repository->find($dataSourceId); + + if (!$dataSource) { + throw new \InvalidArgumentException("数据源不存在: {$dataSourceId}"); + } + + // 如果更新名称,验证唯一性 + if (isset($data['name']) && $data['name'] !== $dataSource->name) { + $existing = $this->repository->newQuery() + ->where('name', $data['name']) + ->where('data_source_id', '!=', $dataSourceId) + ->first(); + + if ($existing) { + throw new \InvalidArgumentException("数据源名称已存在: {$data['name']}"); + } + } + + // 如果设置为标签引擎数据库,自动将其他数据源设置为 false(确保只有一个) + if (isset($data['is_tag_engine']) && !empty($data['is_tag_engine'])) { + // 将所有其他数据源的 is_tag_engine 设置为 false + $this->repository->newQuery() + ->where('data_source_id', '!=', $dataSourceId) + ->update(['is_tag_engine' => false]); + + LoggerHelper::logBusiness('tag_engine_set', [ + 'data_source_id' => $dataSourceId, + 'action' => 'update', + ]); + } + + // 更新数据 + $dataSource->fill($data); + $result = $dataSource->save(); + + if ($result) { + LoggerHelper::logBusiness('data_source_updated', [ + 'data_source_id' => $dataSourceId, + ]); + } + + return $result; + } + + /** + * 删除数据源 + * + * @param string $dataSourceId + * @return bool + */ + public function deleteDataSource(string $dataSourceId): bool + { + $dataSource = $this->repository->find($dataSourceId); + + if (!$dataSource) { + throw new \InvalidArgumentException("数据源不存在: {$dataSourceId}"); + } + + // TODO: 检查是否有任务在使用此数据源 + // 可以查询 DataCollectionTask 中是否有引用此数据源 + + $result = $dataSource->delete(); + + if ($result) { + LoggerHelper::logBusiness('data_source_deleted', [ + 'data_source_id' => $dataSourceId, + ]); + } + + return $result; + } + + /** + * 获取数据源列表 + * + * @param array $filters + * @return array{list: array, total: int} + */ + public function getDataSourceList(array $filters = []): array + { + try { + $query = $this->repository->newQuery(); + + // 筛选条件 + if (isset($filters['type'])) { + $query->where('type', $filters['type']); + } + + if (isset($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (isset($filters['name'])) { + $query->where('name', 'like', '%' . $filters['name'] . '%'); + } + + // 排序 + $query->orderBy('created_at', 'desc'); + + // 分页 + $page = (int)($filters['page'] ?? 1); + $pageSize = (int)($filters['page_size'] ?? 20); + + $total = $query->count(); + $list = $query->skip(($page - 1) * $pageSize) + ->take($pageSize) + ->get() + ->map(function ($item) { + // 不返回密码 + $data = $item->toArray(); + unset($data['password']); + return $data; + }) + ->toArray(); + + return [ + 'list' => $list, + 'total' => $total, + ]; + } catch (\MongoDB\Driver\Exception\Exception $e) { + // MongoDB 连接错误 + LoggerHelper::logError($e, [ + 'component' => 'DataSourceService', + 'action' => 'getDataSourceList', + ]); + throw new \RuntimeException('无法连接到 MongoDB 数据库,请检查数据库服务是否正常运行', 500, $e); + } catch (\Exception $e) { + LoggerHelper::logError($e, [ + 'component' => 'DataSourceService', + 'action' => 'getDataSourceList', + ]); + throw $e; + } + } + + /** + * 获取数据源详情(不包含密码) + * + * @param string $dataSourceId + * @return array|null + */ + public function getDataSourceDetail(string $dataSourceId): ?array + { + $dataSource = $this->repository->find($dataSourceId); + + if (!$dataSource) { + return null; + } + + $data = $dataSource->toArray(); + unset($data['password']); + + return $data; + } + + /** + * 获取数据源详情(包含密码,用于连接) + * + * @param string $dataSourceId + * @return array|null + */ + public function getDataSourceConfig(string $dataSourceId): ?array + { + $dataSource = $this->repository->find($dataSourceId); + + if (!$dataSource) { + return null; + } + + if ($dataSource->status != 1) { + throw new \RuntimeException("数据源已禁用: {$dataSourceId}"); + } + + return $dataSource->toConfigArray(); + } + + /** + * 测试数据源连接 + * + * @param array $config + * @return bool + */ + public function testConnection(array $config): bool + { + try { + $type = strtolower($config['type'] ?? ''); + + // MongoDB特殊处理 + if ($type === 'mongodb') { + // 使用 MongoDBHelper 创建客户端(统一DSN构建逻辑) + $client = \app\utils\MongoDBHelper::createClient($config, [ + 'connectTimeoutMS' => 3000, + 'socketTimeoutMS' => 5000, + ]); + + // 尝试列出数据库来测试连接 + $client->listDatabases(); + return true; + } + + // 其他类型使用适配器 + $adapter = DataSourceAdapterFactory::create($type, $config); + $connected = $adapter->isConnected(); + $adapter->disconnect(); + return $connected; + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'DataSourceService', + 'action' => 'testConnection', + ]); + return false; + } + } + + /** + * 获取所有启用的数据源(用于替代config('data_sources'),从数据库读取) + * + * @return array 以data_source_id为key的配置数组 + */ + public function getAllEnabledDataSources(): array + { + $dataSources = $this->repository->newQuery() + ->where('status', 1) + ->get(); + + $result = []; + foreach ($dataSources as $ds) { + $result[$ds->data_source_id] = $ds->toConfigArray(); + } + + return $result; + } + + /** + * 根据数据源ID获取配置(从数据库读取) + * + * 支持两种查询方式: + * 1. 通过 data_source_id (UUID) 查询 + * 2. 通过 name 字段查询(兼容配置文件中的 key,如 sync_mongodb, tag_mongodb) + * + * @param string $dataSourceId 数据源ID或名称 + * @return array|null 数据源配置,不存在或禁用时返回null + */ + public function getDataSourceConfigById(string $dataSourceId): ?array + { + // \Workerman\Worker::safeEcho("[DataSourceService] 查询数据源配置: data_source_id={$dataSourceId}\n"); + + // 先尝试通过 data_source_id 查询(UUID 格式) + $dataSource = $this->repository->newQuery() + ->where('data_source_id', $dataSourceId) + ->where('status', 1) + ->first(); + + if ($dataSource) { + // \Workerman\Worker::safeEcho("[DataSourceService] ✓ 通过 data_source_id 查询成功: name={$dataSource->name}\n"); + return $dataSource->toConfigArray(); + } + + // 如果通过 data_source_id 查不到,尝试通过 name 字段查询(兼容配置文件中的 key) + // \Workerman\Worker::safeEcho("[DataSourceService] 通过 data_source_id 未找到,尝试通过 name 查询\n"); + + // 处理配置文件中的常见 key 映射 + // 注意:这些映射需要根据实际数据库中的 name 字段值来调整 + $nameMapping = [ + 'sync_mongodb' => '本地大数据库', // 根据实际数据库中的名称调整 + 'tag_mongodb' => '主数据库', // 标签引擎数据库(is_tag_engine=true) + 'kr_mongodb' => '卡若的主机', // 卡若数据库 + ]; + + $searchName = $nameMapping[$dataSourceId] ?? null; + + if ($searchName) { + // \Workerman\Worker::safeEcho("[DataSourceService] 使用映射名称查询: {$dataSourceId} -> {$searchName}\n"); + // 使用映射的名称查询 + $dataSource = $this->repository->newQuery() + ->where('name', $searchName) + ->where('status', 1) + ->first(); + + if ($dataSource) { + // \Workerman\Worker::safeEcho("[DataSourceService] ✓ 通过映射名称查询成功: name={$dataSource->name}, data_source_id={$dataSource->data_source_id}\n"); + return $dataSource->toConfigArray(); + } + } + + // 如果还是查不到,尝试直接使用 dataSourceId 作为 name 查询 + // \Workerman\Worker::safeEcho("[DataSourceService] 尝试直接使用 dataSourceId 作为 name 查询: {$dataSourceId}\n"); + $dataSource = $this->repository->newQuery() + ->where('name', $dataSourceId) + ->where('status', 1) + ->first(); + + if ($dataSource) { + // \Workerman\Worker::safeEcho("[DataSourceService] ✓ 通过 name 直接查询成功: name={$dataSource->name}\n"); + return $dataSource->toConfigArray(); + } + + // 如果还是查不到,对于 tag_mongodb,尝试查询 is_tag_engine=true 的数据源 + if ($dataSourceId === 'tag_mongodb') { + // \Workerman\Worker::safeEcho("[DataSourceService] 对于 tag_mongodb,尝试查询 is_tag_engine=true 的数据源\n"); + $dataSource = $this->repository->newQuery() + ->where('is_tag_engine', true) + ->where('status', 1) + ->first(); + + if ($dataSource) { + // \Workerman\Worker::safeEcho("[DataSourceService] ✓ 通过 is_tag_engine 查询成功: name={$dataSource->name}, data_source_id={$dataSource->data_source_id}\n"); + return $dataSource->toConfigArray(); + } + } + + // \Workerman\Worker::safeEcho("[DataSourceService] ✗ 未找到数据源配置: data_source_id={$dataSourceId}\n"); + return null; + } + + /** + * 获取标签引擎数据库配置(is_tag_engine = true的数据源) + * + * @return array|null 标签引擎数据库配置,未找到时返回null + */ + public function getTagEngineDataSourceConfig(): ?array + { + $dataSource = $this->repository->newQuery() + ->where('is_tag_engine', true) + ->where('status', 1) + ->first(); + + if (!$dataSource) { + return null; + } + + return $dataSource->toConfigArray(); + } + + /** + * 获取标签引擎数据库的data_source_id + * + * @return string|null 标签引擎数据库的data_source_id,未找到时返回null + */ + public function getTagEngineDataSourceId(): ?string + { + $dataSource = $this->repository->newQuery() + ->where('is_tag_engine', true) + ->where('status', 1) + ->first(); + + return $dataSource ? $dataSource->data_source_id : null; + } + + /** + * 验证标签引擎数据库配置是否存在 + * + * @return bool 是否存在标签引擎数据库 + */ + public function hasTagEngineDataSource(): bool + { + $count = $this->repository->newQuery() + ->where('is_tag_engine', true) + ->where('status', 1) + ->count(); + + return $count > 0; + } + + /** + * 获取所有标签引擎数据库(理论上应该只有一个,但允许有多个) + * + * @return array 标签引擎数据库列表 + */ + public function getAllTagEngineDataSources(): array + { + $dataSources = $this->repository->newQuery() + ->where('is_tag_engine', true) + ->where('status', 1) + ->get(); + + $result = []; + foreach ($dataSources as $ds) { + $data = $ds->toArray(); + unset($data['password']); // 不返回密码 + $result[] = $data; + } + + return $result; + } +} + diff --git a/app/service/DataSyncService.php b/app/service/DataSyncService.php new file mode 100644 index 0000000..7c1ee3e --- /dev/null +++ b/app/service/DataSyncService.php @@ -0,0 +1,242 @@ + $messageData 消息数据(包含 source_id、data 等) + * @return array 同步结果 + */ + public function syncData(array $messageData): array + { + $sourceId = $messageData['source_id'] ?? 'unknown'; + $data = $messageData['data'] ?? []; + $count = count($data); + + if (empty($data)) { + LoggerHelper::logBusiness('data_sync_empty', [ + 'source_id' => $sourceId, + ]); + return [ + 'success' => true, + 'synced_count' => 0, + 'skipped_count' => 0, + ]; + } + + LoggerHelper::logBusiness('data_sync_service_started', [ + 'source_id' => $sourceId, + 'data_count' => $count, + ]); + + $syncedCount = 0; + $skippedCount = 0; + $userIds = []; + + // 批量写入消费记录 + foreach ($data as $record) { + try { + // 数据验证 + if (!$this->validateRecord($record)) { + $skippedCount++; + continue; + } + + // 确保有 record_id + if (empty($record['record_id'])) { + $record['record_id'] = (string)Uuid::uuid4(); + } + + // 写入消费记录(使用 Eloquent Model 方式) + $consumptionRecord = new ConsumptionRecordRepository(); + $consumptionRecord->record_id = $record['record_id'] ?? (string)Uuid::uuid4(); + $consumptionRecord->user_id = $record['user_id']; + $consumptionRecord->consume_time = new \DateTimeImmutable($record['consume_time']); + $consumptionRecord->amount = (float)($record['amount'] ?? 0); + $consumptionRecord->actual_amount = (float)($record['actual_amount'] ?? $record['amount'] ?? 0); + $consumptionRecord->currency = $record['currency'] ?? 'CNY'; + $consumptionRecord->store_id = $record['store_id'] ?? ''; + $consumptionRecord->status = $record['status'] ?? 0; + $consumptionRecord->create_time = new \DateTimeImmutable('now'); + $consumptionRecord->save(); + $syncedCount++; + + // 收集用户ID(用于后续批量更新统计) + $userId = $record['user_id'] ?? null; + if ($userId) { + $userIds[] = $userId; + } + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'DataSyncService', + 'action' => 'syncData', + 'source_id' => $sourceId, + 'record' => $record, + ]); + $skippedCount++; + } + } + + // 批量更新用户统计(去重) + $uniqueUserIds = array_unique($userIds); + foreach ($uniqueUserIds as $userId) { + try { + $this->updateUserStatistics($userId); + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'DataSyncService', + 'action' => 'updateUserStatistics', + 'user_id' => $userId, + ]); + } + } + + // 触发标签计算(为每个用户推送消息) + $tagCalculationCount = 0; + foreach ($uniqueUserIds as $userId) { + try { + $message = [ + 'user_id' => $userId, + 'tag_ids' => null, // null 表示计算所有 real_time 标签 + 'trigger_type' => 'data_sync', + 'source_id' => $sourceId, + 'timestamp' => time(), + ]; + + if (QueueService::pushTagCalculation($message)) { + $tagCalculationCount++; + } + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'DataSyncService', + 'action' => 'triggerTagCalculation', + 'user_id' => $userId, + ]); + } + } + + $result = [ + 'success' => true, + 'synced_count' => $syncedCount, + 'skipped_count' => $skippedCount, + 'user_count' => count($uniqueUserIds), + 'tag_calculation_triggered' => $tagCalculationCount, + ]; + + LoggerHelper::logBusiness('data_sync_service_completed', array_merge([ + 'source_id' => $sourceId, + ], $result)); + + return $result; + } + + /** + * 验证记录 + * + * @param array $record 记录数据 + * @return bool 是否通过验证 + */ + private function validateRecord(array $record): bool + { + // 必填字段验证 + $requiredFields = ['user_id', 'amount', 'consume_time']; + foreach ($requiredFields as $field) { + if (!isset($record[$field]) || $record[$field] === null || $record[$field] === '') { + return false; + } + } + + // 金额验证 + $amount = (float)($record['amount'] ?? 0); + if ($amount <= 0) { + return false; + } + + // 时间格式验证 + $consumeTime = $record['consume_time'] ?? ''; + if (strtotime($consumeTime) === false) { + return false; + } + + return true; + } + + /** + * 更新用户统计 + * + * @param string $userId 用户ID + * @return void + */ + private function updateUserStatistics(string $userId): void + { + // 获取用户的所有消费记录(用于重新计算统计) + // 这里简化处理,只更新最近的数据 + // 实际场景中,可以增量更新或全量重新计算 + + // 查询用户最近的消费记录(使用 Eloquent 查询) + $records = ConsumptionRecordRepository::where('user_id', $userId) + ->orderBy('consume_time', 'desc') + ->limit(1000) + ->get() + ->toArray(); + + if (empty($records)) { + return; + } + + // 计算统计值 + $totalAmount = 0; + $totalCount = count($records); + $lastConsumeTime = null; + + foreach ($records as $record) { + $amount = (float)($record['amount'] ?? 0); + $totalAmount += $amount; + + $consumeTime = $record['consume_time'] ?? null; + if ($consumeTime) { + $time = strtotime($consumeTime); + if ($time && ($lastConsumeTime === null || $time > $lastConsumeTime)) { + $lastConsumeTime = $time; + } + } + } + + // 更新用户档案(使用 increaseStats 方法,但这里需要全量更新) + // 简化处理:直接更新统计字段 + $user = $this->userProfileRepository->findByUserId($userId); + if ($user) { + $user->total_amount = $totalAmount; + $user->total_count = $totalCount; + if ($lastConsumeTime) { + $user->last_consume_time = new \DateTimeImmutable('@' . $lastConsumeTime); + } + $user->save(); + } + } +} + diff --git a/app/service/DatabaseSyncService.php b/app/service/DatabaseSyncService.php new file mode 100644 index 0000000..8bf8329 --- /dev/null +++ b/app/service/DatabaseSyncService.php @@ -0,0 +1,1417 @@ + 0, + 'collections' => 0, + 'documents_inserted' => 0, + 'documents_updated' => 0, + 'documents_deleted' => 0, + 'errors' => 0, + 'last_sync_time' => null, + ]; + // 同步进度信息 + private array $progress = [ + 'status' => 'idle', // idle, full_sync, incremental_sync, error + 'current_database' => null, + 'current_collection' => null, + 'databases_total' => 0, + 'databases_completed' => 0, + 'collections_total' => 0, + 'collections_completed' => 0, + // 文档级进度(行数) + 'documents_total' => 0, + 'documents_processed' => 0, + // 数据量级进度(基于 collStats / dbStats 估算的字节数) + 'bytes_total' => 0, + // 已经清空过的目标数据库列表,避免重复清空影响断点续传 + 'cleared_databases' => [], + // 源端数据库的集合快照(用于检测“同名库但结构已变更/被重建”的情况) + // 结构示例:'collections_snapshot' => ['KR' => ['coll1', 'coll2', ...]] + 'collections_snapshot' => [], + // 在历史进度中出现过,但当前源库已不存在的数据库(用于给出提醒) + 'orphan_databases' => [], + // 断点续传检查点:按数据库/集合记录最后一个处理的 _id 和已处理数量 + // 结构示例: + // 'checkpoints' => [ + // 'KR_腾讯' => [ + // '某集合名' => [ + // 'last_id' => 'xxx', + // 'processed' => 123, + // 'completed' => false, + // ], + // ], + // ], + 'checkpoints' => [], + // bytes_processed 不单独持久化,在 getProgress 中按 documents 比例动态估算 + 'start_time' => null, + 'current_database_start_time' => null, + 'estimated_time_remaining' => null, + 'last_error' => null, // 记录最后一次错误信息 + 'error_database' => null, // 出错的数据库名称 + ]; + + /** + * 构造函数 + * + * @param array|null $config 配置数组,必须包含 'source' 和 'target' 数据库配置 + * 如果为 null 或配置无效,将跳过数据库连接初始化(仅用于读取进度文件) + * 注意:config('database_sync') 已废弃,必须通过 DatabaseSyncHandler 传递配置 + * + * @throws \InvalidArgumentException 如果配置为 null 且无效 + */ + public function __construct(?array $config = null) + { + if ($config === null) { + throw new \InvalidArgumentException( + 'DatabaseSyncService 必须传递配置参数。' . + 'config(\'database_sync\') 已废弃,请使用 DatabaseSyncHandler 传递配置。' + ); + } + $this->config = $config; + + try { + // 只有在配置有效时才初始化数据库连接(用于查询进度时可能不需要连接) + if ($this->hasValidConfig()) { + $this->initClients(); + LoggerHelper::logBusiness('database_sync_service_initialized', [ + 'source' => $this->config['source']['host'] . ':' . $this->config['source']['port'], + 'target' => $this->config['target']['host'] . ':' . $this->config['target']['port'], + ]); + } + $this->loadProgress(); + } catch (\Exception $e) { + LoggerHelper::logError($e, [ + 'action' => 'database_sync_service_init_error', + ]); + throw $e; + } + } + + /** + * 检查配置是否有效(用于判断是否需要初始化数据库连接) + * + * @return bool + */ + private function hasValidConfig(): bool + { + $sourceHost = $this->config['source']['host'] ?? ''; + $sourcePort = $this->config['source']['port'] ?? 0; + $targetHost = $this->config['target']['host'] ?? ''; + $targetPort = $this->config['target']['port'] ?? 0; + + return !empty($sourceHost) && $sourcePort > 0 && !empty($targetHost) && $targetPort > 0; + } + + /** + * 初始化数据库连接 + */ + private function initClients(): void + { + // 源数据库连接 + $sourceConfig = $this->config['source']; + $sourceDsn = $this->buildDsn($sourceConfig); + $this->sourceClient = new Client($sourceDsn, $sourceConfig['options']); + + // 目标数据库连接 + $targetConfig = $this->config['target']; + $targetDsn = $this->buildDsn($targetConfig); + $this->targetClient = new Client($targetDsn, $targetConfig['options']); + + LoggerHelper::logBusiness('database_sync_clients_initialized', [ + 'source' => $sourceConfig['host'] . ':' . $sourceConfig['port'], + 'target' => $targetConfig['host'] . ':' . $targetConfig['port'], + ]); + } + + /** + * 构建 MongoDB DSN + */ + private function buildDsn(array $config): string + { + // 验证必需的配置项 + $host = $config['host'] ?? ''; + $port = $config['port'] ?? 0; + + if (empty($host)) { + throw new \InvalidArgumentException( + 'MongoDB host 配置为空。请设置环境变量 DB_SYNC_SOURCE_HOST 和 DB_SYNC_TARGET_HOST' + ); + } + + if (empty($port) || $port <= 0) { + throw new \InvalidArgumentException( + 'MongoDB port 配置无效。请设置环境变量 DB_SYNC_SOURCE_PORT 和 DB_SYNC_TARGET_PORT' + ); + } + + $dsn = 'mongodb://'; + if (!empty($config['username']) && !empty($config['password'])) { + $dsn .= urlencode($config['username']) . ':' . urlencode($config['password']) . '@'; + } + $dsn .= $host . ':' . $port; + if (!empty($config['auth_source'])) { + $dsn .= '/?authSource=' . urlencode($config['auth_source']); + } + return $dsn; + } + + /** + * 获取要同步的数据库列表 + */ + public function getDatabasesToSync(): array + { + try { + $databases = $this->sourceClient->listDatabases(); + $databasesToSync = []; + // 记录每个数据库的大致大小,用于排序(小库优先同步) + $databaseSizes = []; + $excludeDatabases = $this->config['sync']['exclude_databases'] ?? []; + + $currentDbNames = []; + foreach ($databases as $databaseInfo) { + $dbName = (string)$databaseInfo->getName(); + $currentDbNames[] = $dbName; + + // 记录源端数据库的大致大小(单位:字节),用于后续排序 + try { + $sizeOnDisk = method_exists($databaseInfo, 'getSizeOnDisk') + ? (int)$databaseInfo->getSizeOnDisk() + : 0; + $databaseSizes[$dbName] = $sizeOnDisk; + } catch (\Throwable $e) { + $databaseSizes[$dbName] = 0; + } + + // 排除系统数据库 + if (in_array($dbName, $excludeDatabases)) { + continue; + } + + // 如果指定了要同步的数据库列表,只同步列表中的 + $syncDatabases = $this->config['sync']['databases'] ?? []; + if (!empty($syncDatabases) && !in_array($dbName, $syncDatabases)) { + continue; + } + + $databasesToSync[] = $dbName; + } + + // 检测历史进度中曾经同步过,但当前源库已不存在的“孤儿数据库” + $knownDbNames = array_keys($this->progress['collections_snapshot'] ?? []); + $orphanDatabases = $this->progress['orphan_databases'] ?? []; + foreach ($knownDbNames as $knownDb) { + if (!in_array($knownDb, $currentDbNames, true) && !in_array($knownDb, $orphanDatabases, true)) { + $orphanDatabases[] = $knownDb; + LoggerHelper::logBusiness('database_sync_source_database_missing', [ + 'database' => $knownDb, + ], 'warning'); + } + } + $this->progress['orphan_databases'] = $orphanDatabases; + + // 更新进度信息 + // 根据数据库大小排序:小的优先同步,便于尽快完成更多库,提高“完成感” + usort($databasesToSync, function (string $a, string $b) use ($databaseSizes): int { + $sizeA = $databaseSizes[$a] ?? PHP_INT_MAX; + $sizeB = $databaseSizes[$b] ?? PHP_INT_MAX; + if ($sizeA === $sizeB) { + return strcmp($a, $b); + } + return $sizeA <=> $sizeB; + }); + + $this->progress['databases_total'] = count($databasesToSync); + // 如果是首次获取数据库列表(start_time 为空),才重置 completed 计数, + // 避免在进程中途多次调用时把已完成的统计清零。 + if ($this->progress['start_time'] === null) { + $this->progress['databases_completed'] = 0; + } + if ($this->progress['start_time'] === null) { + $this->progress['start_time'] = microtime(true); + } + $this->saveProgress(); + + return $databasesToSync; + } catch (MongoDBException $e) { + LoggerHelper::logError($e, [ + 'action' => 'database_sync_list_databases_error', + ]); + return []; + } + } + + /** + * 确保目标数据库存在,如果不存在则创建 + */ + private function ensureTargetDatabaseExists(string $databaseName): void + { + try { + // 检查目标数据库是否存在 + $targetDatabases = $this->targetClient->listDatabases(); + $databaseExists = false; + + foreach ($targetDatabases as $dbInfo) { + if ($dbInfo->getName() === $databaseName) { + $databaseExists = true; + break; + } + } + + // 如果数据库不存在,创建一个临时集合并插入一条记录来触发数据库创建 + if (!$databaseExists) { + $targetDb = $this->targetClient->selectDatabase($databaseName); + $tempCollection = $targetDb->selectCollection('__temp_sync_init__'); + + // 插入一条临时记录来创建数据库 + $tempCollection->insertOne(['_created' => new \MongoDB\BSON\UTCDateTime()]); + + // 删除临时集合 + $tempCollection->drop(); + + LoggerHelper::logBusiness('database_sync_database_created', [ + 'database' => $databaseName, + 'target' => $this->config['target']['host'] . ':' . $this->config['target']['port'], + ]); + } + } catch (MongoDBException $e) { + LoggerHelper::logError($e, [ + 'action' => 'database_sync_ensure_database_error', + 'database' => $databaseName, + ]); + // 不抛出异常,继续执行同步(MongoDB 会在第一次插入时自动创建数据库) + } + } + + /** + * 清空目标数据库(用于全量同步前的初始化) + * + * 注意: + * - 仅在首次同步该数据库时调用(通过 progress.cleared_databases 控制) + * - 后续断点续传时不会再次清空,避免丢失已同步的数据 + */ + private function clearTargetDatabase(string $databaseName): void + { + try { + $targetDb = $this->targetClient->selectDatabase($databaseName); + $targetDb->drop(); + + LoggerHelper::logBusiness('database_sync_target_database_cleared', [ + 'database' => $databaseName, + 'target' => $this->config['target']['host'] . ':' . $this->config['target']['port'], + ]); + } catch (MongoDBException $e) { + LoggerHelper::logError($e, [ + 'action' => 'database_sync_clear_target_error', + 'database' => $databaseName, + ]); + // 清空失败属于严重问题,这里抛出异常,避免在脏数据基础上继续同步 + throw $e; + } + } + + /** + * 全量同步数据库 + */ + public function fullSyncDatabase(string $databaseName): bool + { + try { + // 更新进度状态 + if ($this->progress['start_time'] === null) { + $this->progress['start_time'] = microtime(true); + } + $this->progress['status'] = 'full_sync'; + $this->progress['current_database'] = $databaseName; + $this->progress['current_database_start_time'] = microtime(true); + $this->saveProgress(); + + LoggerHelper::logBusiness('database_sync_database_start', [ + 'database' => $databaseName, + 'status' => 'full_sync', + ]); + + // 确保目标数据库存在 + $this->ensureTargetDatabaseExists($databaseName); + + // 如果尚未清空过该目标数据库,则执行一次清空(适用于你当前“目标库可以清空”的场景) + $clearedDatabases = $this->progress['cleared_databases'] ?? []; + if (!in_array($databaseName, $clearedDatabases, true)) { + $this->clearTargetDatabase($databaseName); + $clearedDatabases[] = $databaseName; + $this->progress['cleared_databases'] = $clearedDatabases; + $this->saveProgress(); + } + + $sourceDb = $this->sourceClient->selectDatabase($databaseName); + $targetDb = $this->targetClient->selectDatabase($databaseName); + + // 获取所有集合 + $collections = $sourceDb->listCollections(); + $batchSize = $this->config['sync']['change_stream']['full_sync_batch_size'] ?? 1000; + $excludeCollections = $this->config['sync']['exclude_collections'] ?? []; + + // 统计集合总数,同时预估总文档数和总数据量(用于更精确的进度估算) + $collectionList = []; + $totalDocuments = 0; + $totalBytes = 0; + + foreach ($collections as $collectionInfo) { + $collectionName = $collectionInfo->getName(); + if (in_array($collectionName, $excludeCollections)) { + continue; + } + + $collectionList[] = $collectionName; + + try { + // 使用 collStats 获取集合的文档数和大小 + $statsCursor = $sourceDb->command(['collStats' => $collectionName]); + $statsArray = $statsCursor->toArray(); + $collStats = $statsArray[0] ?? []; + + $collCount = (int)($collStats['count'] ?? 0); + $collSizeBytes = (int)($collStats['size'] ?? 0); + + $totalDocuments += $collCount; + $totalBytes += $collSizeBytes; + } catch (MongoDBException $e) { + // 单个集合统计失败不影响整体同步,只记录日志 + LoggerHelper::logError($e, [ + 'action' => 'database_sync_collstats_error', + 'database' => $databaseName, + 'collection' => $collectionName, + ]); + } + } + + // 按名称排序,便于与历史快照稳定对比 + sort($collectionList); + + // 检测同名数据库结构是否发生重大变化(例如:被删除后重建) + $collectionsSnapshot = $this->progress['collections_snapshot'] ?? []; + $previousSnapshot = $collectionsSnapshot[$databaseName] ?? null; + if ($previousSnapshot !== null && $previousSnapshot !== $collectionList) { + // 源库结构变化:为了避免旧 checkpoint 导致数据不一致,将该库视为“新库”,重新清空目标并丢弃旧断点 + LoggerHelper::logBusiness('database_sync_source_schema_changed', [ + 'database' => $databaseName, + 'previous_collections' => $previousSnapshot, + 'current_collections' => $collectionList, + ], 'warning'); + + // 重新清空目标库 + $this->clearTargetDatabase($databaseName); + // 丢弃该库的旧断点 + unset($this->progress['checkpoints'][$databaseName]); + // 标记为已清空 + $clearedDatabases = $this->progress['cleared_databases'] ?? []; + if (!in_array($databaseName, $clearedDatabases, true)) { + $clearedDatabases[] = $databaseName; + } + $this->progress['cleared_databases'] = $clearedDatabases; + } + + // 记录当前集合快照 + $collectionsSnapshot[$databaseName] = $collectionList; + $this->progress['collections_snapshot'] = $collectionsSnapshot; + + $this->progress['collections_total'] = count($collectionList); + $this->progress['collections_completed'] = 0; + // 为整个数据库预先写入总文档数和总数据量(按库维度估算进度) + if ($totalDocuments > 0) { + $this->progress['documents_total'] = $totalDocuments; + } + if ($totalBytes > 0) { + $this->progress['bytes_total'] = $totalBytes; + } + // 每次开始全量同步时重置已处理文档数 + $this->progress['documents_processed'] = 0; + $this->saveProgress(); + + // 根据配置决定是否并行同步集合 + $enableParallel = $this->config['sync']['performance']['enable_parallel_sync'] ?? true; + $concurrentCollections = $this->config['sync']['performance']['concurrent_collections'] ?? 10; + + if ($enableParallel && count($collectionList) > 1) { + // 并行同步多个集合 + $this->syncCollectionsParallel($sourceDb, $targetDb, $collectionList, $databaseName, $batchSize, $concurrentCollections); + } else { + // 顺序同步集合 + foreach ($collectionList as $collectionName) { + $this->syncCollection($sourceDb, $targetDb, $collectionName, $databaseName, $batchSize); + } + } + + $this->stats['databases']++; + $this->progress['databases_completed']++; + $this->progress['current_database'] = null; + $this->progress['current_collection'] = null; + $this->saveProgress(); + + return true; + } catch (MongoDBException $e) { + // 记录错误信息,但不停止整个同步流程 + $errorMessage = $e->getMessage(); + $this->progress['status'] = 'error'; + $this->progress['last_error'] = [ + 'message' => $errorMessage, + 'database' => $databaseName, + 'collection' => $this->progress['current_collection'], + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'time' => date('Y-m-d H:i:s'), + ]; + $this->progress['error_database'] = $databaseName; + $this->saveProgress(); + + LoggerHelper::logError($e, [ + 'action' => 'database_sync_full_sync_error', + 'database' => $databaseName, + 'collection' => $this->progress['current_collection'], + ]); + $this->stats['errors']++; + + // 不返回 false,让调用者决定是否继续同步其他数据库 + // 这样可以跳过有问题的数据库,继续同步其他数据库 + return false; + } + } + + /** + * 同步单个集合(支持大数据量分片) + * + * 错误隔离:集合级错误不会影响其他集合的同步 + */ + private function syncCollection(Database $sourceDb, Database $targetDb, string $collectionName, string $databaseName, int $batchSize): void + { + $this->progress['current_collection'] = $collectionName; + $this->saveProgress(); + + LoggerHelper::logBusiness('database_sync_full_sync_collection_start', [ + 'database' => $databaseName, + 'collection' => $collectionName, + ]); + + try { + $sourceCollection = $sourceDb->selectCollection($collectionName); + $targetCollection = $targetDb->selectCollection($collectionName); + + // 统计当前集合文档总数(用于分片和日志),但不再覆盖全局 documents_total, + // 全库的总文档数在 fullSyncDatabase 中基于 collStats 预估 + $totalDocuments = $sourceCollection->countDocuments([]); + + // 检查是否需要分片处理(大数据量) + $documentsPerTask = $this->config['sync']['performance']['documents_per_task'] ?? 100000; + $enableParallel = $this->config['sync']['performance']['enable_parallel_sync'] ?? true; + $maxParallelTasks = $this->config['sync']['performance']['max_parallel_tasks_per_collection'] ?? 4; + + if ($enableParallel && $totalDocuments > $documentsPerTask && $maxParallelTasks > 1) { + // 大数据量集合,使用分片并行处理 + $this->syncCollectionParallel($sourceCollection, $targetCollection, $collectionName, $databaseName, $batchSize, $totalDocuments, $maxParallelTasks); + } else { + // 小数据量集合,直接同步 + $this->syncCollectionSequential($sourceCollection, $targetCollection, $collectionName, $databaseName, $batchSize); + } + + LoggerHelper::logBusiness('database_sync_full_sync_collection_complete', [ + 'database' => $databaseName, + 'collection' => $collectionName, + 'count' => $totalDocuments, + ]); + + $this->stats['collections']++; + $this->progress['collections_completed']++; + } catch (\Throwable $e) { + // 集合级错误隔离:记录错误但继续同步其他集合 + LoggerHelper::logError($e, [ + 'action' => 'database_sync_collection_error', + 'database' => $databaseName, + 'collection' => $collectionName, + ]); + + $this->stats['errors']++; + + // 记录集合级错误到进度文件 + $this->progress['last_error'] = [ + 'message' => $e->getMessage(), + 'database' => $databaseName, + 'collection' => $collectionName, + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'time' => date('Y-m-d H:i:s'), + ]; + + // 仍然标记集合为已完成(跳过),继续同步其他集合 + $this->stats['collections']++; + $this->progress['collections_completed']++; + } finally { + $this->progress['current_collection'] = null; + $this->saveProgress(); + } + } + + /** + * 顺序同步集合(小数据量) + */ + private function syncCollectionSequential(Collection $sourceCollection, Collection $targetCollection, string $collectionName, string $databaseName, int $batchSize): void + { + // 从断点读取上次同步位置(基于 _id 断点) + $checkpoint = $this->progress['checkpoints'][$databaseName][$collectionName] ?? null; + $lastId = $checkpoint['last_id'] ?? null; + + $filter = []; + if ($lastId) { + try { + $filter['_id'] = ['$gt' => new \MongoDB\BSON\ObjectId($lastId)]; + } catch (\Throwable $e) { + // 如果 last_id 无法解析为 ObjectId,则退回全量同步 + LoggerHelper::logError($e, [ + 'action' => 'database_sync_invalid_checkpoint_id', + 'database' => $databaseName, + 'collection' => $collectionName, + 'last_id' => $lastId, + ]); + $filter = []; + } + } + + $options = [ + 'batchSize' => $batchSize, + // 确保按 _id 递增,便于基于 _id 做断点续传 + 'sort' => ['_id' => 1], + ]; + + $cursor = $sourceCollection->find($filter, $options); + $batch = []; + $lastProgressLogTime = time(); + + foreach ($cursor as $document) { + $batch[] = $document; + + if (count($batch) >= $batchSize) { + $this->batchInsert($targetCollection, $batch); + $batchCount = count($batch); + $this->progress['documents_processed'] += $batchCount; + + // 记录本批次最后一个文档的 _id 作为断点 + $lastDoc = end($batch); + if (isset($lastDoc['_id'])) { + $this->progress['checkpoints'][$databaseName][$collectionName] = [ + 'last_id' => (string)$lastDoc['_id'], + 'processed' => ($this->progress['checkpoints'][$databaseName][$collectionName]['processed'] ?? 0) + $batchCount, + 'completed' => false, + ]; + } + + $batch = []; + + // 每5秒输出一次进度 + if (time() - $lastProgressLogTime >= 5) { + $this->logProgress(); + $lastProgressLogTime = time(); + } + $this->saveProgress(); + } + } + + // 处理剩余数据 + if (!empty($batch)) { + $this->batchInsert($targetCollection, $batch); + $batchCount = count($batch); + $this->progress['documents_processed'] += $batchCount; + + $lastDoc = end($batch); + if (isset($lastDoc['_id'])) { + $this->progress['checkpoints'][$databaseName][$collectionName] = [ + 'last_id' => (string)$lastDoc['_id'], + 'processed' => ($this->progress['checkpoints'][$databaseName][$collectionName]['processed'] ?? 0) + $batchCount, + 'completed' => true, + ]; + } + + $this->saveProgress(); + } + } + + /** + * 并行同步集合(大数据量,使用分片) + */ + private function syncCollectionParallel(Collection $sourceCollection, Collection $targetCollection, string $collectionName, string $databaseName, int $batchSize, int $totalDocuments, int $maxParallelTasks): void + { + // 计算每个任务处理的文档数 + $documentsPerTask = (int)ceil($totalDocuments / $maxParallelTasks); + + LoggerHelper::logBusiness('database_sync_collection_parallel_start', [ + 'database' => $databaseName, + 'collection' => $collectionName, + 'total_documents' => $totalDocuments, + 'parallel_tasks' => $maxParallelTasks, + 'documents_per_task' => $documentsPerTask, + ]); + + // 创建任务列表 + $tasks = []; + for ($i = 0; $i < $maxParallelTasks; $i++) { + $skip = $i * $documentsPerTask; + $limit = min($documentsPerTask, $totalDocuments - $skip); + + if ($limit <= 0) { + break; + } + + $tasks[] = [ + 'skip' => $skip, + 'limit' => $limit, + 'task_id' => $i + 1, + ]; + } + + // 使用 Workerman 的协程或进程并行执行 + $this->executeParallelTasks($sourceCollection, $targetCollection, $tasks, $batchSize); + } + + /** + * 执行并行任务 + * + * 注意:由于 Workerman Coroutine 可能存在类加载冲突问题,这里使用顺序执行 + * MongoDB 操作本身已经很快,顺序执行也能保证良好的性能 + */ + private function executeParallelTasks(Collection $sourceCollection, Collection $targetCollection, array $tasks, int $batchSize): void + { + // 顺序执行任务(避免协程类加载冲突) + foreach ($tasks as $task) { + $this->syncCollectionChunk($sourceCollection, $targetCollection, $task['skip'], $task['limit'], $batchSize); + } + } + + /** + * 同步集合的一个分片 + */ + private function syncCollectionChunk(Collection $sourceCollection, Collection $targetCollection, int $skip, int $limit, int $batchSize): void + { + $cursor = $sourceCollection->find([], [ + 'skip' => $skip, + 'limit' => $limit, + 'batchSize' => $batchSize, + ]); + + $batch = []; + $count = 0; + + foreach ($cursor as $document) { + $batch[] = $document; + $count++; + + if (count($batch) >= $batchSize) { + $this->batchInsert($targetCollection, $batch); + $this->progress['documents_processed'] += count($batch); + $batch = []; + $this->saveProgress(); + } + } + + // 处理剩余数据 + if (!empty($batch)) { + $this->batchInsert($targetCollection, $batch); + $this->progress['documents_processed'] += count($batch); + $this->saveProgress(); + } + } + + /** + * 并行同步多个集合 + */ + private function syncCollectionsParallel(Database $sourceDb, Database $targetDb, array $collectionList, string $databaseName, int $batchSize, int $concurrentCollections): void + { + // 将集合列表分成多个批次 + $chunks = array_chunk($collectionList, $concurrentCollections); + + foreach ($chunks as $chunk) { + // 顺序同步当前批次(避免协程类加载冲突) + // 注意:虽然配置了并发集合数,但由于协程存在类加载问题,这里使用顺序执行 + // MongoDB 操作本身已经很快,顺序执行也能保证良好的性能 + foreach ($chunk as $collectionName) { + $this->syncCollection($sourceDb, $targetDb, $collectionName, $databaseName, $batchSize); + } + } + } + + /** + * 批量插入文档(支持重试机制) + * + * 错误隔离:文档级错误不会影响其他批次的同步 + */ + private function batchInsert(Collection $collection, array $documents): void + { + if (empty($documents)) { + return; + } + + $maxRetries = $this->config['sync']['retry']['max_sync_retries'] ?? 3; + $retryDelay = $this->config['sync']['retry']['sync_retry_interval'] ?? 2; + $retryCount = 0; + + while ($retryCount <= $maxRetries) { + try { + // 使用 bulkWrite 进行批量写入 + $operations = []; + foreach ($documents as $doc) { + $operations[] = [ + 'insertOne' => [$doc], + ]; + } + + $collection->bulkWrite($operations, ['ordered' => false]); + $this->stats['documents_inserted'] += count($documents); + return; // 成功,退出重试循环 + } catch (MongoDBException $e) { + $retryCount++; + + if ($retryCount > $maxRetries) { + // 超过最大重试次数,记录错误但继续处理下一批 + LoggerHelper::logError($e, [ + 'action' => 'database_sync_batch_insert_error', + 'collection' => $collection->getCollectionName(), + 'count' => count($documents), + 'retry_count' => $retryCount - 1, + ]); + + $this->stats['errors']++; + + // 对于文档级错误,不抛出异常,继续处理下一批 + // 这样可以保证即使某些文档失败,也能继续同步其他文档 + return; + } + + // 指数退避重试 + $delay = $retryDelay * pow(2, $retryCount - 1); + LoggerHelper::logBusiness('database_sync_batch_insert_retry', [ + 'collection' => $collection->getCollectionName(), + 'retry_count' => $retryCount, + 'max_retries' => $maxRetries, + 'delay' => $delay, + ]); + + // 等待后重试 + sleep($delay); + } + } + } + + /** + * 监听数据库变化并同步 + * + * 注意:此方法会阻塞,需要在独立进程中运行 + */ + public function watchDatabase(string $databaseName): void + { + try { + // 确保目标数据库存在 + $this->ensureTargetDatabaseExists($databaseName); + + $sourceDb = $this->sourceClient->selectDatabase($databaseName); + $targetDb = $this->targetClient->selectDatabase($databaseName); + + $batchSize = $this->config['sync']['change_stream']['batch_size'] ?? 100; + $maxAwaitTimeMs = $this->config['sync']['change_stream']['max_await_time_ms'] ?? 1000; + $excludeCollections = $this->config['sync']['exclude_collections'] ?? []; + + // 使用数据库级别的 Change Stream(MongoDB 4.0+) + // 这样可以监听整个数据库的所有集合变化 + $changeStream = $sourceDb->watch( + [], + [ + 'fullDocument' => 'updateLookup', + 'batchSize' => $batchSize, + 'maxAwaitTimeMS' => $maxAwaitTimeMs, + ] + ); + + LoggerHelper::logBusiness('database_sync_watch_database_start', [ + 'database' => $databaseName, + ]); + + // 处理变更事件 + foreach ($changeStream as $change) { + $collectionName = $change['ns']['coll'] ?? ''; + + // 排除系统集合 + if (in_array($collectionName, $excludeCollections)) { + continue; + } + + // 获取目标集合 + $targetCollection = $targetDb->selectCollection($collectionName); + + // 处理变更 + $this->processChange($targetCollection, $change); + $this->stats['last_sync_time'] = time(); + $this->progress['status'] = 'incremental_sync'; + $this->saveProgress(); + } + } catch (MongoDBException $e) { + LoggerHelper::logError($e, [ + 'action' => 'database_sync_watch_database_error', + 'database' => $databaseName, + ]); + throw $e; + } + } + + + /** + * 处理变更事件 + */ + private function processChange(Collection $targetCollection, $change): void + { + try { + $operationType = $change['operationType'] ?? ''; + + switch ($operationType) { + case 'insert': + $this->handleInsert($targetCollection, $change); + break; + case 'update': + case 'replace': + $this->handleUpdate($targetCollection, $change); + break; + case 'delete': + $this->handleDelete($targetCollection, $change); + break; + default: + LoggerHelper::logBusiness('database_sync_unknown_operation', [ + 'operation' => $operationType, + 'collection' => $targetCollection->getCollectionName(), + ]); + } + } catch (MongoDBException $e) { + LoggerHelper::logError($e, [ + 'action' => 'database_sync_process_change_error', + 'collection' => $targetCollection->getCollectionName(), + 'operation' => $change['operationType'] ?? 'unknown', + ]); + $this->stats['errors']++; + } + } + + /** + * 处理插入操作 + */ + private function handleInsert(Collection $targetCollection, array $change): void + { + $document = $change['fullDocument'] ?? null; + if ($document) { + $targetCollection->insertOne($document); + $this->stats['documents_inserted']++; + + if ($this->config['monitoring']['log_detail'] ?? false) { + LoggerHelper::logBusiness('database_sync_insert', [ + 'collection' => $targetCollection->getCollectionName(), + 'document_id' => (string)($document['_id'] ?? ''), + ]); + } + } + } + + /** + * 处理更新操作 + */ + private function handleUpdate(Collection $targetCollection, array $change): void + { + $documentId = $change['documentKey']['_id'] ?? null; + $fullDocument = $change['fullDocument'] ?? null; + + if ($documentId) { + if ($fullDocument) { + // 使用完整文档替换 + $targetCollection->replaceOne( + ['_id' => $documentId], + $fullDocument, + ['upsert' => true] + ); + } else { + // 使用更新操作 + $updateDescription = $change['updateDescription'] ?? []; + $updatedFields = $updateDescription['updatedFields'] ?? []; + $removedFields = $updateDescription['removedFields'] ?? []; + + $update = []; + if (!empty($updatedFields)) { + $update['$set'] = $updatedFields; + } + if (!empty($removedFields)) { + $update['$unset'] = array_fill_keys($removedFields, ''); + } + + if (!empty($update)) { + $targetCollection->updateOne( + ['_id' => $documentId], + $update, + ['upsert' => true] + ); + } + } + $this->stats['documents_updated']++; + + if ($this->config['monitoring']['log_detail'] ?? false) { + LoggerHelper::logBusiness('database_sync_update', [ + 'collection' => $targetCollection->getCollectionName(), + 'document_id' => (string)$documentId, + ]); + } + } + } + + /** + * 处理删除操作 + */ + private function handleDelete(Collection $targetCollection, array $change): void + { + $documentId = $change['documentKey']['_id'] ?? null; + if ($documentId) { + $targetCollection->deleteOne(['_id' => $documentId]); + $this->stats['documents_deleted']++; + + if ($this->config['monitoring']['log_detail'] ?? false) { + LoggerHelper::logBusiness('database_sync_delete', [ + 'collection' => $targetCollection->getCollectionName(), + 'document_id' => (string)$documentId, + ]); + } + } + } + + /** + * 获取同步统计信息 + */ + public function getStats(): array + { + return $this->stats; + } + + /** + * 获取同步进度信息 + */ + public function getProgress(): array + { + // 计算进度百分比(优先使用文档级进度,更准确) + $progressPercent = 0; + + // 方法1:基于文档数计算(最准确) + if ($this->progress['documents_total'] > 0 && $this->progress['documents_processed'] > 0) { + $docProgress = ($this->progress['documents_processed'] / $this->progress['documents_total']) * 100; + $progressPercent = round($docProgress, 2); + } + // 方法2:基于数据库和集合计算(备用) + elseif ($this->progress['databases_total'] > 0) { + $dbProgress = ($this->progress['databases_completed'] / $this->progress['databases_total']) * 100; + + // 如果当前正在处理某个数据库,考虑集合进度 + if ($this->progress['collections_total'] > 0 && $this->progress['current_database']) { + $collectionProgress = ($this->progress['collections_completed'] / $this->progress['collections_total']) * 100; + // 当前数据库的进度 = 已完成数据库数 + 当前数据库的集合进度 + $dbProgress = ($this->progress['databases_completed'] + ($collectionProgress / 100)) / $this->progress['databases_total'] * 100; + } + + $progressPercent = round($dbProgress, 2); + } + + // 确保进度在 0-100 之间 + $progressPercent = max(0, min(100, $progressPercent)); + + // 计算已用时间 + $elapsedTime = null; + if ($this->progress['start_time']) { + $elapsedTime = round(microtime(true) - $this->progress['start_time'], 2); + } + + // 基于文档数和预估总数据量,计算按“数据量”的同步进度(字节级) + $bytesTotal = (int)($this->progress['bytes_total'] ?? 0); + $bytesProcessed = 0; + if ($bytesTotal > 0 && $this->progress['documents_total'] > 0) { + $ratio = $this->progress['documents_processed'] / max(1, $this->progress['documents_total']); + if ($ratio > 1) { + $ratio = 1; + } elseif ($ratio < 0) { + $ratio = 0; + } + $bytesProcessed = (int)round($bytesTotal * $ratio); + } + + // 计算预计剩余时间 + $estimatedRemaining = null; + if ($progressPercent > 0 && $elapsedTime) { + $totalEstimatedTime = $elapsedTime / ($progressPercent / 100); + $estimatedRemaining = round($totalEstimatedTime - $elapsedTime, 2); + } + + return [ + 'status' => $this->progress['status'], + 'progress_percent' => $progressPercent, + 'current_database' => $this->progress['current_database'], + 'current_collection' => $this->progress['current_collection'], + 'databases' => [ + 'total' => $this->progress['databases_total'], + 'completed' => $this->progress['databases_completed'], + 'remaining' => $this->progress['databases_total'] - $this->progress['databases_completed'], + ], + 'collections' => [ + 'total' => $this->progress['collections_total'], + 'completed' => $this->progress['collections_completed'], + 'remaining' => $this->progress['collections_total'] - $this->progress['collections_completed'], + ], + 'documents' => [ + 'total' => $this->progress['documents_total'], + 'processed' => $this->progress['documents_processed'], + 'remaining' => max(0, $this->progress['documents_total'] - $this->progress['documents_processed']), + ], + 'bytes' => [ + 'total' => $bytesTotal, + 'processed' => $bytesProcessed, + 'remaining' => max(0, $bytesTotal - $bytesProcessed), + ], + 'time' => [ + 'elapsed_seconds' => $elapsedTime, + 'estimated_remaining_seconds' => $estimatedRemaining, + 'start_time' => $this->progress['start_time'] ? date('Y-m-d H:i:s', (int)$this->progress['start_time']) : null, + ], + 'stats' => $this->stats, + 'last_error' => $this->progress['last_error'] ?? null, + 'error_database' => $this->progress['error_database'] ?? null, + ]; + } + + /** + * 重置进度并清除错误状态(用于恢复同步) + */ + public function resetProgress(): void + { + $this->resetStats(); + LoggerHelper::logBusiness('database_sync_progress_reset', []); + } + + /** + * 跳过当前错误数据库,继续同步下一个 + */ + public function skipErrorDatabase(): bool + { + if ($this->progress['status'] === 'error' && $this->progress['error_database']) { + $errorDb = $this->progress['error_database']; + + // 标记该数据库为已完成(跳过) + $this->stats['databases']++; + $this->progress['databases_completed']++; + + // 清除错误状态 + $this->progress['status'] = 'full_sync'; + $this->progress['current_database'] = null; + $this->progress['current_collection'] = null; + $this->progress['error_database'] = null; + $this->progress['last_error'] = null; + + $this->saveProgress(); + + LoggerHelper::logBusiness('database_sync_skip_error_database', [ + 'database' => $errorDb, + ]); + + return true; + } + return false; + } + + /** + * 获取运行时目录路径 + */ + private function getRuntimePath(): string + { + if (function_exists('runtime_path')) { + $path = runtime_path(); + } else { + $basePath = function_exists('base_path') ? base_path() : __DIR__ . '/../../'; + $path = config('app.runtime_path', $basePath . DIRECTORY_SEPARATOR . 'runtime'); + } + if (!is_dir($path)) { + mkdir($path, 0777, true); + } + return $path; + } + + /** + * 保存进度到文件(用于多进程共享) + * + * 使用文件锁(LOCK_EX)保证多进程写入的原子性,避免并发冲突 + */ + private function saveProgress(): void + { + $progressFile = $this->getRuntimePath() . DIRECTORY_SEPARATOR . 'database_sync_progress.json'; + + // 使用文件锁保证原子性写入 + $fp = fopen($progressFile, 'c+'); // 'c+' 模式:如果文件不存在则创建,如果存在则打开用于读写 + if ($fp === false) { + LoggerHelper::logError(new \RuntimeException("无法打开进度文件: {$progressFile}"), [ + 'action' => 'database_sync_save_progress_error', + ]); + return; + } + + // 获取独占锁(LOCK_EX),阻塞直到获取锁 + if (flock($fp, LOCK_EX)) { + try { + // 读取现有进度(如果存在),智能合并更新 + $existingContent = stream_get_contents($fp); + if ($existingContent) { + $existingProgress = json_decode($existingContent, true); + if ($existingProgress && is_array($existingProgress)) { + // 智能合并策略: + // 1. 保留全局统计信息(databases_total, collections_total 等) + // 2. 合并 checkpoints(每个进程只更新自己负责的数据库) + // 3. 合并 cleared_databases(避免重复清空) + // 4. 合并 collections_snapshot(保留所有数据库的快照) + // 5. 更新当前进程的进度信息 + + // 保留全局统计(取最大值,确保不丢失) + $this->progress['databases_total'] = max( + $this->progress['databases_total'] ?? 0, + $existingProgress['databases_total'] ?? 0 + ); + $this->progress['collections_total'] = max( + $this->progress['collections_total'] ?? 0, + $existingProgress['collections_total'] ?? 0 + ); + $this->progress['documents_total'] = max( + $this->progress['documents_total'] ?? 0, + $existingProgress['documents_total'] ?? 0 + ); + $this->progress['bytes_total'] = max( + $this->progress['bytes_total'] ?? 0, + $existingProgress['bytes_total'] ?? 0 + ); + + // 对于 completed 计数,需要累加(多进程场景) + // 但由于每个进程只处理部分数据库,直接累加会导致重复计数 + // 因此采用基于 checkpoints 重新计算的方式 + // 这里先保留现有值,在 getProgress 中基于 checkpoints 重新计算 + // 为了简化,这里采用取最大值的方式(每个进程只更新自己完成的部分) + // 注意:这种方式在多进程场景下可能不够精确,但可以避免重复计数 + $this->progress['databases_completed'] = max( + $this->progress['databases_completed'] ?? 0, + $existingProgress['databases_completed'] ?? 0 + ); + $this->progress['collections_completed'] = max( + $this->progress['collections_completed'] ?? 0, + $existingProgress['collections_completed'] ?? 0 + ); + $this->progress['documents_processed'] = max( + $this->progress['documents_processed'] ?? 0, + $existingProgress['documents_processed'] ?? 0 + ); + + // 合并 checkpoints(每个进程只更新自己负责的数据库) + if (isset($existingProgress['checkpoints']) && is_array($existingProgress['checkpoints'])) { + if (!isset($this->progress['checkpoints'])) { + $this->progress['checkpoints'] = []; + } + $this->progress['checkpoints'] = array_merge( + $existingProgress['checkpoints'], + $this->progress['checkpoints'] + ); + } + + // 合并 cleared_databases(避免重复清空) + if (isset($existingProgress['cleared_databases']) && is_array($existingProgress['cleared_databases'])) { + if (!isset($this->progress['cleared_databases'])) { + $this->progress['cleared_databases'] = []; + } + $this->progress['cleared_databases'] = array_unique(array_merge( + $existingProgress['cleared_databases'], + $this->progress['cleared_databases'] + )); + } + + // 合并 collections_snapshot(保留所有数据库的快照) + if (isset($existingProgress['collections_snapshot']) && is_array($existingProgress['collections_snapshot'])) { + if (!isset($this->progress['collections_snapshot'])) { + $this->progress['collections_snapshot'] = []; + } + $this->progress['collections_snapshot'] = array_merge( + $existingProgress['collections_snapshot'], + $this->progress['collections_snapshot'] + ); + } + + // 合并 orphan_databases + if (isset($existingProgress['orphan_databases']) && is_array($existingProgress['orphan_databases'])) { + if (!isset($this->progress['orphan_databases'])) { + $this->progress['orphan_databases'] = []; + } + $this->progress['orphan_databases'] = array_unique(array_merge( + $existingProgress['orphan_databases'], + $this->progress['orphan_databases'] + )); + } + + // 保留最早的 start_time + if (isset($existingProgress['start_time']) && $existingProgress['start_time'] > 0) { + if (!isset($this->progress['start_time']) || $this->progress['start_time'] === null) { + $this->progress['start_time'] = $existingProgress['start_time']; + } else { + $this->progress['start_time'] = min( + $this->progress['start_time'], + $existingProgress['start_time'] + ); + } + } + } + } + + // 清空文件并写入合并后的进度 + ftruncate($fp, 0); + rewind($fp); + fwrite($fp, json_encode($this->progress, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + fflush($fp); // 确保立即写入磁盘 + } finally { + // 释放锁 + flock($fp, LOCK_UN); + } + } else { + LoggerHelper::logError(new \RuntimeException("无法获取进度文件锁: {$progressFile}"), [ + 'action' => 'database_sync_save_progress_lock_error', + ]); + } + + fclose($fp); + } + + /** + * 设置进度状态(公开方法,供外部调用) + */ + public function setProgressStatus(string $status): void + { + $this->progress['status'] = $status; + if ($status !== 'idle' && $this->progress['start_time'] === null) { + $this->progress['start_time'] = microtime(true); + } + $this->saveProgress(); + } + + /** + * 从文件加载进度(使用文件锁保证读取一致性) + */ + public function loadProgress(): void + { + $progressFile = $this->getRuntimePath() . DIRECTORY_SEPARATOR . 'database_sync_progress.json'; + if (!file_exists($progressFile)) { + return; + } + + // 使用文件锁保证读取一致性 + $fp = fopen($progressFile, 'r'); + if ($fp === false) { + LoggerHelper::logError(new \RuntimeException("无法打开进度文件: {$progressFile}"), [ + 'action' => 'database_sync_load_progress_error', + ]); + return; + } + + // 获取共享锁(LOCK_SH),允许多个进程同时读取 + if (flock($fp, LOCK_SH)) { + try { + $content = stream_get_contents($fp); + if ($content) { + $loaded = json_decode($content, true); + if ($loaded && is_array($loaded)) { + // 合并进度:保留现有字段,更新加载的字段 + $this->progress = array_merge($this->progress, $loaded); + } + } + } finally { + // 释放锁 + flock($fp, LOCK_UN); + } + } else { + LoggerHelper::logError(new \RuntimeException("无法获取进度文件锁: {$progressFile}"), [ + 'action' => 'database_sync_load_progress_lock_error', + ]); + } + + fclose($fp); + } + + /** + * 输出进度日志 + */ + private function logProgress(): void + { + $progress = $this->getProgress(); + LoggerHelper::logBusiness('database_sync_progress', [ + 'status' => $progress['status'], + 'progress_percent' => $progress['progress_percent'] . '%', + 'current_database' => $progress['current_database'], + 'current_collection' => $progress['current_collection'], + 'databases' => "{$progress['databases']['completed']}/{$progress['databases']['total']}", + 'collections' => "{$progress['collections']['completed']}/{$progress['collections']['total']}", + 'documents' => "{$progress['documents']['processed']}/{$progress['documents']['total']}", + 'elapsed_time' => $progress['time']['elapsed_seconds'] . 's', + 'estimated_remaining' => $progress['time']['estimated_remaining_seconds'] ? $progress['time']['estimated_remaining_seconds'] . 's' : 'calculating...', + ]); + } + + /** + * 重置统计信息 + */ + public function resetStats(): void + { + $this->stats = [ + 'databases' => 0, + 'collections' => 0, + 'documents_inserted' => 0, + 'documents_updated' => 0, + 'documents_deleted' => 0, + 'errors' => 0, + 'last_sync_time' => null, + ]; + $this->progress = [ + 'status' => 'idle', + 'current_database' => null, + 'current_collection' => null, + 'databases_total' => 0, + 'databases_completed' => 0, + 'collections_total' => 0, + 'collections_completed' => 0, + 'documents_total' => 0, + 'documents_processed' => 0, + 'bytes_total' => 0, + 'cleared_databases' => [], + 'collections_snapshot' => [], + 'orphan_databases' => [], + 'start_time' => null, + 'current_database_start_time' => null, + 'estimated_time_remaining' => null, + 'last_error' => null, + 'error_database' => null, + ]; + $this->saveProgress(); + } +} + diff --git a/app/service/IdentifierService.php b/app/service/IdentifierService.php new file mode 100644 index 0000000..08eac2a --- /dev/null +++ b/app/service/IdentifierService.php @@ -0,0 +1,312 @@ +createTemporaryPerson(null, $atTime); + LoggerHelper::logBusiness('temporary_person_created_no_phone', [ + 'user_id' => $userId, + 'note' => '手机号为空,创建无手机号的临时用户', + ]); + return $userId; + } + + // 1. 先查询手机号关联表(使用指定的时间点) + $userId = $this->userPhoneService->findUserByPhone($trimmedPhone, $atTime); + + if ($userId !== null) { + LoggerHelper::logBusiness('person_resolved_by_phone', [ + 'phone_number' => $trimmedPhone, + 'user_id' => $userId, + 'source' => 'existing_relation', + 'at_time' => $atTime ? $atTime->format('Y-m-d H:i:s') : null, + ]); + return $userId; + } + + // 2. 如果找不到,创建临时人(使用atTime作为生效时间) + $userId = $this->createTemporaryPerson($trimmedPhone, $atTime); + + LoggerHelper::logBusiness('temporary_person_created', [ + 'phone_number' => $trimmedPhone, + 'user_id' => $userId, + 'effective_time' => $atTime ? $atTime->format('Y-m-d H:i:s') : null, + ]); + + return $userId; + } + + /** + * 根据身份证解析用户ID(person_id) + * + * @param string $idCard 身份证号 + * @return string|null user_id(person_id),如果不存在返回null + */ + public function resolvePersonIdByIdCard(string $idCard): ?string + { + $idCardHash = EncryptionHelper::hash($idCard); + $user = $this->userProfileRepository->findByIdCardHash($idCardHash); + + if ($user) { + LoggerHelper::logBusiness('person_resolved_by_id_card', [ + 'id_card_hash' => $idCardHash, + 'user_id' => $user->user_id, + ]); + return $user->user_id; + } + + return null; + } + + /** + * 绑定身份证到用户(将临时人转为正式人,或创建正式人) + * + * @param string $userId 用户ID + * @param string $idCard 身份证号 + * @return bool 是否成功 + * @throws \InvalidArgumentException + */ + public function bindIdCardToPerson(string $userId, string $idCard): bool + { + $idCardHash = EncryptionHelper::hash($idCard); + $idCardEncrypted = EncryptionHelper::encrypt($idCard); + + // 检查该身份证是否已被其他用户使用 + $existingUser = $this->userProfileRepository->findByIdCardHash($idCardHash); + if ($existingUser && $existingUser->user_id !== $userId) { + throw new \InvalidArgumentException("身份证号已被其他用户使用,user_id: {$existingUser->user_id}"); + } + + // 更新用户信息 + $user = $this->userProfileRepository->findByUserId($userId); + if (!$user) { + throw new \InvalidArgumentException("用户不存在: {$userId}"); + } + + // 如果用户已经是正式人且身份证匹配,无需更新 + if (!$user->is_temporary && $user->id_card_hash === $idCardHash) { + return true; + } + + // 更新身份证信息并标记为正式人 + $user->id_card_hash = $idCardHash; + $user->id_card_encrypted = $idCardEncrypted; + $user->id_card_type = '身份证'; + $user->is_temporary = false; + + // 从身份证号中自动提取基础信息(如果字段为空才更新) + $idCardInfo = IdCardHelper::extractInfo($idCard); + if ($idCardInfo['birthday'] !== null && $user->birthday === null) { + $user->birthday = $idCardInfo['birthday']; + } + // 只有当性别解析成功且当前值为 null 时才更新(0 也被认为是未设置) + if ($idCardInfo['gender'] > 0 && ($user->gender === null || $user->gender === 0)) { + $user->gender = $idCardInfo['gender']; + } + + $user->update_time = new \DateTimeImmutable('now'); + $user->save(); + + LoggerHelper::logBusiness('id_card_bound_to_person', [ + 'user_id' => $userId, + 'id_card_hash' => $idCardHash, + 'was_temporary' => $user->is_temporary ?? true, + ]); + + return true; + } + + /** + * 创建临时人 + * + * @param string|null $phoneNumber 手机号(可选,用于建立关联) + * @param \DateTimeInterface|null $effectiveTime 生效时间(用于手机关联,默认当前时间) + * @return string user_id + */ + private function createTemporaryPerson(?string $phoneNumber = null, ?\DateTimeInterface $effectiveTime = null): string + { + $now = new \DateTimeImmutable('now'); + $userId = UuidGenerator::uuid4()->toString(); + + // 创建临时人记录 + $user = new UserProfileRepository(); + $user->user_id = $userId; + $user->is_temporary = true; + $user->status = 0; + $user->total_amount = 0; + $user->total_count = 0; + $user->create_time = $now; + $user->update_time = $now; + $user->save(); + + // 如果有手机号,建立关联(使用effectiveTime作为生效时间) + // 检查手机号不为空(null 或空字符串都跳过) + if ($phoneNumber !== null && trim($phoneNumber) !== '') { + try { + $trimmedPhone = trim($phoneNumber); + $this->userPhoneService->addPhoneToUser($userId, $trimmedPhone, [ + 'source' => 'auto_created', + 'type' => 'personal', + 'effective_time' => $effectiveTime ?? $now, + ]); + + LoggerHelper::logBusiness('phone_relation_created_success', [ + 'user_id' => $userId, + 'phone_number' => $trimmedPhone, + 'effective_time' => ($effectiveTime ?? $now)->format('Y-m-d H:i:s'), + ]); + } catch (\Throwable $e) { + // 手机号关联失败不影响用户创建,只记录详细的错误日志 + LoggerHelper::logError($e, [ + 'component' => 'IdentifierService', + 'action' => 'createTemporaryPerson', + 'user_id' => $userId, + 'phone_number' => $phoneNumber, + 'phone_number_length' => strlen($phoneNumber), + 'error_message' => $e->getMessage(), + 'error_type' => get_class($e), + ]); + + // 同时记录业务日志,便于排查 + LoggerHelper::logBusiness('phone_relation_create_failed', [ + 'user_id' => $userId, + 'phone_number' => $phoneNumber, + 'error_message' => $e->getMessage(), + 'note' => '用户已创建,但手机关联失败', + ]); + } + } elseif ($phoneNumber !== null && trim($phoneNumber) === '') { + // 手机号是空字符串,记录日志 + LoggerHelper::logBusiness('phone_relation_skipped_empty', [ + 'user_id' => $userId, + 'note' => '手机号为空字符串,跳过关联创建', + ]); + } + + return $userId; + } + + /** + * 根据手机号或身份证解析用户ID + * + * 优先级:身份证 > 手机号 + * + * @param string|null $phoneNumber 手机号 + * @param string|null $idCard 身份证号 + * @param \DateTimeInterface|null $atTime 查询时间点(用于手机号查询,默认为当前时间) + * @return string user_id + */ + public function resolvePersonId(?string $phoneNumber = null, ?string $idCard = null, ?\DateTimeInterface $atTime = null): string + { + $atTime = $atTime ?? new \DateTimeImmutable('now'); + + // 优先使用身份证 + if ($idCard !== null && !empty($idCard)) { + $userId = $this->resolvePersonIdByIdCard($idCard); + if ($userId !== null) { + // 如果身份证存在,但提供了手机号,确保手机号关联到该用户 + if ($phoneNumber !== null && !empty($phoneNumber)) { + // 在atTime时间点查询手机号关联 + $existingUserId = $this->userPhoneService->findUserByPhone($phoneNumber, $atTime); + if ($existingUserId === null) { + // 手机号未关联,建立关联(使用atTime作为生效时间) + $this->userPhoneService->addPhoneToUser($userId, $phoneNumber, [ + 'source' => 'id_card_resolved', + 'type' => 'personal', + 'effective_time' => $atTime, + ]); + } elseif ($existingUserId !== $userId) { + // 手机号已关联到其他用户,需要合并(由PersonMergeService处理) + LoggerHelper::logBusiness('phone_bound_to_different_person', [ + 'phone_number' => $phoneNumber, + 'existing_user_id' => $existingUserId, + 'id_card_user_id' => $userId, + 'at_time' => $atTime->format('Y-m-d H:i:s'), + ]); + } + } + return $userId; + } else { + // 身份证不存在,但有身份证信息,创建一个临时用户并绑定身份证(使其成为正式用户) + $userId = $this->createTemporaryPerson($phoneNumber, $atTime); + try { + $this->bindIdCardToPerson($userId, $idCard); + } catch (\Throwable $e) { + // 绑定失败不影响返回user_id + LoggerHelper::logError($e, [ + 'component' => 'IdentifierService', + 'action' => 'resolvePersonId', + 'user_id' => $userId, + ]); + } + return $userId; + } + } + + // 使用手机号(传入atTime) + if ($phoneNumber !== null && !empty($phoneNumber)) { + $userId = $this->resolvePersonIdByPhone($phoneNumber, $atTime); + + // 如果同时提供了身份证,绑定身份证 + if ($idCard !== null && !empty($idCard)) { + try { + $this->bindIdCardToPerson($userId, $idCard); + } catch (\Throwable $e) { + // 绑定失败不影响返回user_id + LoggerHelper::logError($e, [ + 'component' => 'IdentifierService', + 'action' => 'resolvePersonId', + 'user_id' => $userId, + ]); + } + } + + return $userId; + } + + // 都没有提供,创建临时人 + return $this->createTemporaryPerson(null, $atTime); + } +} + diff --git a/app/service/PersonMergeService.php b/app/service/PersonMergeService.php new file mode 100644 index 0000000..31ec697 --- /dev/null +++ b/app/service/PersonMergeService.php @@ -0,0 +1,497 @@ +userProfileRepository->findByUserId($tempUserId); + if (!$tempUser) { + throw new \InvalidArgumentException("临时人不存在: {$tempUserId}"); + } + + if (!$tempUser->is_temporary) { + throw new \InvalidArgumentException("用户不是临时人: {$tempUserId}"); + } + + $idCardHash = \app\utils\EncryptionHelper::hash($idCard); + + // 查找该身份证是否已有正式人 + $formalUser = $this->userProfileRepository->findByIdCardHash($idCardHash); + + if ($formalUser) { + // 情况1:身份证已存在,合并临时人到正式人 + if ($formalUser->user_id === $tempUserId) { + // 已经是同一个人,只需标记为正式人(传入原始身份证号以提取信息) + $this->userProfileRepository->markAsFormal($tempUserId, $idCardHash, \app\utils\EncryptionHelper::encrypt($idCard), $idCard); + return $tempUserId; + } + + // 合并到已存在的正式人 + $this->mergeUsers($tempUserId, $formalUser->user_id); + return $formalUser->user_id; + } else { + // 情况2:身份证不存在,将临时人转为正式人(传入原始身份证号以提取信息) + $this->userProfileRepository->markAsFormal($tempUserId, $idCardHash, \app\utils\EncryptionHelper::encrypt($idCard), $idCard); + + $tempUser->id_card_type = '身份证'; + $tempUser->save(); + + LoggerHelper::logBusiness('temporary_person_converted_to_formal', [ + 'user_id' => $tempUserId, + 'id_card_hash' => $idCardHash, + ]); + + // 重新计算标签 + $this->recalculateTags($tempUserId); + + return $tempUserId; + } + } + + /** + * 合并两个用户(将sourceUserId合并到targetUserId) + * + * @param string $sourceUserId 源用户ID(将被合并的用户) + * @param string $targetUserId 目标用户ID(保留的用户) + * @return bool 是否成功 + */ + public function mergeUsers(string $sourceUserId, string $targetUserId): bool + { + if ($sourceUserId === $targetUserId) { + return true; + } + + $sourceUser = $this->userProfileRepository->findByUserId($sourceUserId); + $targetUser = $this->userProfileRepository->findByUserId($targetUserId); + + if (!$sourceUser || !$targetUser) { + throw new \InvalidArgumentException("用户不存在: source={$sourceUserId}, target={$targetUserId}"); + } + + LoggerHelper::logBusiness('person_merge_started', [ + 'source_user_id' => $sourceUserId, + 'target_user_id' => $targetUserId, + ]); + + try { + // 1. 合并统计数据 + $this->mergeStatistics($sourceUser, $targetUser); + + // 2. 合并手机号关联 + $this->mergePhoneRelations($sourceUserId, $targetUserId); + + // 3. 合并标签 + $this->mergeTags($sourceUserId, $targetUserId); + + // 4. 合并消费记录(更新user_id) + $this->mergeConsumptionRecords($sourceUserId, $targetUserId); + + // 5. 记录合并历史 + $this->recordMergeHistory($sourceUserId, $targetUserId); + + // 6. 标记源用户为已合并 + $sourceUser->status = 1; // 标记为已删除/已合并 + $sourceUser->merged_from_user_id = $targetUserId; // 记录合并到的目标用户ID + $sourceUser->update_time = new \DateTimeImmutable('now'); + $sourceUser->save(); + + // 7. 更新目标用户的标签更新时间 + $targetUser->tags_update_time = new \DateTimeImmutable('now'); + $targetUser->update_time = new \DateTimeImmutable('now'); + $targetUser->save(); + + LoggerHelper::logBusiness('person_merge_completed', [ + 'source_user_id' => $sourceUserId, + 'target_user_id' => $targetUserId, + ]); + + // 8. 重新计算目标用户的标签 + $this->recalculateTags($targetUserId); + + return true; + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'PersonMergeService', + 'action' => 'mergeUsers', + 'source_user_id' => $sourceUserId, + 'target_user_id' => $targetUserId, + ]); + throw $e; + } + } + + /** + * 合并统计数据 + * + * @param UserProfileRepository $sourceUser + * @param UserProfileRepository $targetUser + */ + private function mergeStatistics(UserProfileRepository $sourceUser, UserProfileRepository $targetUser): void + { + // 合并总金额和总次数 + $targetUser->total_amount = (float)($targetUser->total_amount ?? 0) + (float)($sourceUser->total_amount ?? 0); + $targetUser->total_count = (int)($targetUser->total_count ?? 0) + (int)($sourceUser->total_count ?? 0); + + // 取更晚的最后消费时间 + if ($sourceUser->last_consume_time && + (!$targetUser->last_consume_time || $sourceUser->last_consume_time > $targetUser->last_consume_time)) { + $targetUser->last_consume_time = $sourceUser->last_consume_time; + } + + $targetUser->save(); + } + + /** + * 合并手机号关联 + * + * @param string $sourceUserId + * @param string $targetUserId + */ + private function mergePhoneRelations(string $sourceUserId, string $targetUserId): void + { + // 获取源用户的所有手机号 + $sourcePhones = $this->userPhoneService->getUserPhoneNumbers($sourceUserId, false); + + foreach ($sourcePhones as $phoneNumber) { + try { + // 将手机号关联到目标用户 + $this->userPhoneService->addPhoneToUser($targetUserId, $phoneNumber, [ + 'source' => 'person_merge', + 'type' => 'personal', + ]); + + // 失效源用户的手机号关联 + $this->userPhoneService->removePhoneFromUser($sourceUserId, $phoneNumber); + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'PersonMergeService', + 'action' => 'mergePhoneRelations', + 'source_user_id' => $sourceUserId, + 'target_user_id' => $targetUserId, + 'phone_number' => $phoneNumber, + ]); + } + } + } + + /** + * 合并标签 + * + * 智能合并策略: + * 1. 如果目标用户没有该标签,直接复制源用户的标签 + * 2. 如果目标用户已有该标签,根据标签类型和定义决定合并策略: + * - 数值型标签(number):根据标签定义的聚合方式(sum/max/min/avg)合并 + * - 布尔型标签(boolean):取 OR(任一为true则为true) + * - 字符串型标签(string):保留目标用户的值(不覆盖) + * - 枚举型标签:保留目标用户的值(不覆盖) + * 3. 置信度取两者中的较高值 + * + * @param string $sourceUserId + * @param string $targetUserId + */ + private function mergeTags(string $sourceUserId, string $targetUserId): void + { + $sourceTags = $this->userTagRepository->newQuery() + ->where('user_id', $sourceUserId) + ->get(); + + // 获取标签定义,用于判断合并策略 + $tagDefinitionRepo = new \app\repository\TagDefinitionRepository(); + + foreach ($sourceTags as $sourceTag) { + // 检查目标用户是否已有该标签 + $targetTag = $this->userTagRepository->newQuery() + ->where('user_id', $targetUserId) + ->where('tag_id', $sourceTag->tag_id) + ->first(); + + if (!$targetTag) { + // 目标用户没有该标签,复制源用户的标签 + $newTag = new UserTagRepository(); + $newTag->user_id = $targetUserId; + $newTag->tag_id = $sourceTag->tag_id; + $newTag->tag_value = $sourceTag->tag_value; + $newTag->tag_value_type = $sourceTag->tag_value_type; + $newTag->confidence = $sourceTag->confidence; + $newTag->effective_time = $sourceTag->effective_time; + $newTag->expire_time = $sourceTag->expire_time; + $newTag->create_time = new \DateTimeImmutable('now'); + $newTag->update_time = new \DateTimeImmutable('now'); + $newTag->save(); + } else { + // 目标用户已有标签,根据类型智能合并 + $mergedValue = $this->mergeTagValue( + $sourceTag, + $targetTag, + $tagDefinitionRepo->newQuery()->where('tag_id', $sourceTag->tag_id)->first() + ); + + if ($mergedValue !== null) { + $targetTag->tag_value = $mergedValue; + $targetTag->confidence = max((float)$sourceTag->confidence, (float)$targetTag->confidence); + $targetTag->update_time = new \DateTimeImmutable('now'); + $targetTag->save(); + } + } + } + + // 删除源用户的标签 + $this->userTagRepository->newQuery() + ->where('user_id', $sourceUserId) + ->delete(); + } + + /** + * 合并标签值 + * + * @param UserTagRepository $sourceTag 源标签 + * @param UserTagRepository $targetTag 目标标签 + * @param \app\repository\TagDefinitionRepository|null $tagDef 标签定义(可选) + * @return string|null 合并后的标签值,如果不需要更新返回null + */ + private function mergeTagValue( + UserTagRepository $sourceTag, + UserTagRepository $targetTag, + ?\app\repository\TagDefinitionRepository $tagDef = null + ): ?string { + $sourceValue = $sourceTag->tag_value; + $targetValue = $targetTag->tag_value; + $sourceType = $sourceTag->tag_value_type; + $targetType = $targetTag->tag_value_type; + + // 如果类型不一致,保留目标值 + if ($sourceType !== $targetType) { + return null; + } + + // 根据类型合并 + switch ($targetType) { + case 'number': + // 数值型:根据标签定义的聚合方式合并 + $aggregation = null; + if ($tagDef && isset($tagDef->rule_config)) { + $ruleConfig = is_string($tagDef->rule_config) + ? json_decode($tagDef->rule_config, true) + : $tagDef->rule_config; + $aggregation = $ruleConfig['aggregation'] ?? 'sum'; + } else { + $aggregation = 'sum'; // 默认累加 + } + + $sourceNum = (float)$sourceValue; + $targetNum = (float)$targetValue; + + return match($aggregation) { + 'sum' => (string)($sourceNum + $targetNum), + 'max' => (string)max($sourceNum, $targetNum), + 'min' => (string)min($sourceNum, $targetNum), + 'avg' => (string)(($sourceNum + $targetNum) / 2), + default => (string)($sourceNum + $targetNum), // 默认累加 + }; + + case 'boolean': + // 布尔型:取 OR(任一为true则为true) + $sourceBool = $sourceValue === 'true' || $sourceValue === '1'; + $targetBool = $targetValue === 'true' || $targetValue === '1'; + return ($sourceBool || $targetBool) ? 'true' : 'false'; + + case 'string': + case 'json': + default: + // 字符串型、JSON型等:保留目标值(不覆盖) + return null; + } + } + + /** + * 合并消费记录 + * + * @param string $sourceUserId + * @param string $targetUserId + */ + private function mergeConsumptionRecords(string $sourceUserId, string $targetUserId): void + { + // 更新消费记录的user_id + ConsumptionRecordRepository::where('user_id', $sourceUserId) + ->update(['user_id' => $targetUserId]); + } + + /** + * 重新计算用户标签 + * + * @param string $userId + */ + private function recalculateTags(string $userId): void + { + try { + // 异步触发标签计算 + QueueService::pushTagCalculation([ + 'user_id' => $userId, + 'tag_ids' => null, // 计算所有标签 + 'trigger_type' => 'person_merge', + 'timestamp' => time(), + ]); + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'PersonMergeService', + 'action' => 'recalculateTags', + 'user_id' => $userId, + ]); + } + } + + /** + * 根据手机号发现身份证后,合并相关用户 + * + * 这是场景4的实现:如果某个手机号发现了对应的身份证号, + * 查询该身份下是否有标签,如果有就会将对应的这个身份证号的所有标签重新计算同步。 + * + * @param string $phoneNumber 手机号 + * @param string $idCard 身份证号 + * @return string 正式人的user_id + */ + public function mergePhoneToIdCard(string $phoneNumber, string $idCard): string + { + // 1. 查找手机号对应的用户 + $phoneUserId = $this->userPhoneService->findUserByPhone($phoneNumber); + + // 2. 查找身份证对应的用户 + $idCardHash = \app\utils\EncryptionHelper::hash($idCard); + $idCardUser = $this->userProfileRepository->findByIdCardHash($idCardHash); + + if ($idCardUser && $phoneUserId && $idCardUser->user_id === $phoneUserId) { + // 已经是同一个人,只需确保是正式人(传入原始身份证号以提取信息) + if ($idCardUser->is_temporary) { + $this->userProfileRepository->markAsFormal($phoneUserId, $idCardHash, \app\utils\EncryptionHelper::encrypt($idCard), $idCard); + } + $this->recalculateTags($phoneUserId); + return $phoneUserId; + } + + if ($idCardUser && $phoneUserId && $idCardUser->user_id !== $phoneUserId) { + // 身份证和手机号对应不同用户,需要合并 + $this->mergeUsers($phoneUserId, $idCardUser->user_id); + $this->recalculateTags($idCardUser->user_id); + return $idCardUser->user_id; + } + + if ($idCardUser && !$phoneUserId) { + // 身份证存在,但手机号未关联,建立关联 + $this->userPhoneService->addPhoneToUser($idCardUser->user_id, $phoneNumber, [ + 'source' => 'id_card_discovered', + 'type' => 'personal', + ]); + $this->recalculateTags($idCardUser->user_id); + return $idCardUser->user_id; + } + + if (!$idCardUser && $phoneUserId) { + // 手机号存在,但身份证不存在,将临时人转为正式人 + return $this->mergeTemporaryToFormal($phoneUserId, $idCard); + } + + // 都不存在,创建正式人 + $userId = \Ramsey\Uuid\Uuid::uuid4()->toString(); + $now = new \DateTimeImmutable('now'); + + // 从身份证号中自动提取基础信息 + $idCardInfo = \app\utils\IdCardHelper::extractInfo($idCard); + + $user = new UserProfileRepository(); + $user->user_id = $userId; + $user->id_card_hash = $idCardHash; + $user->id_card_encrypted = \app\utils\EncryptionHelper::encrypt($idCard); + $user->id_card_type = '身份证'; + $user->is_temporary = false; + $user->status = 0; + $user->total_amount = 0; + $user->total_count = 0; + $user->birthday = $idCardInfo['birthday']; // 可能为 null + $user->gender = $idCardInfo['gender'] > 0 ? $idCardInfo['gender'] : null; // 解析失败则为 null + $user->create_time = $now; + $user->update_time = $now; + $user->save(); + + $this->userPhoneService->addPhoneToUser($userId, $phoneNumber, [ + 'source' => 'new_created', + 'type' => 'personal', + ]); + + return $userId; + } + + /** + * 记录合并历史 + * + * @param string $sourceUserId 源用户ID + * @param string $targetUserId 目标用户ID + */ + private function recordMergeHistory(string $sourceUserId, string $targetUserId): void + { + try { + $sourceUser = $this->userProfileRepository->findByUserId($sourceUserId); + $targetUser = $this->userProfileRepository->findByUserId($targetUserId); + + if (!$sourceUser || !$targetUser) { + return; + } + + // 记录合并信息到日志(可以扩展为独立的合并历史表) + LoggerHelper::logBusiness('person_merge_history', [ + 'source_user_id' => $sourceUserId, + 'target_user_id' => $targetUserId, + 'source_is_temporary' => $sourceUser->is_temporary ?? true, + 'target_is_temporary' => $targetUser->is_temporary ?? false, + 'source_id_card_hash' => $sourceUser->id_card_hash ?? null, + 'target_id_card_hash' => $targetUser->id_card_hash ?? null, + 'merge_time' => date('Y-m-d H:i:s'), + ]); + } catch (\Throwable $e) { + // 记录历史失败不影响合并流程 + LoggerHelper::logError($e, [ + 'component' => 'PersonMergeService', + 'action' => 'recordMergeHistory', + 'source_user_id' => $sourceUserId, + 'target_user_id' => $targetUserId, + ]); + } + } +} + diff --git a/app/service/StoreService.php b/app/service/StoreService.php new file mode 100644 index 0000000..3313bfa --- /dev/null +++ b/app/service/StoreService.php @@ -0,0 +1,164 @@ + $extraData 额外的门店信息 + * @return string 门店ID + */ + public function getOrCreateStoreByName( + string $storeName, + ?string $source = null, + array $extraData = [] + ): string { + // 1. 先查找是否已存在同名门店(正常状态) + $existingStore = $this->storeRepository->findByStoreName($storeName); + + if ($existingStore) { + LoggerHelper::logBusiness('store_found_by_name', [ + 'store_name' => $storeName, + 'store_id' => $existingStore->store_id, + ]); + return $existingStore->store_id; + } + + // 2. 如果不存在,创建新门店 + $storeId = $this->createStore($storeName, $source, $extraData); + + LoggerHelper::logBusiness('store_created_by_name', [ + 'store_name' => $storeName, + 'store_id' => $storeId, + 'source' => $source, + ]); + + return $storeId; + } + + /** + * 创建门店 + * + * @param string $storeName 门店名称 + * @param string|null $source 数据源标识 + * @param array $extraData 额外的门店信息 + * @return string 门店ID + */ + public function createStore(string $storeName, ?string $source = null, array $extraData = []): string + { + $now = new \DateTimeImmutable('now'); + $storeId = UuidGenerator::uuid4()->toString(); + + // 生成门店编码:如果提供了store_code则使用,否则自动生成 + $storeCode = $extraData['store_code'] ?? $this->generateStoreCode($storeName, $source); + + // 检查门店编码是否已存在 + $existingStore = $this->storeRepository->findByStoreCode($storeCode); + if ($existingStore) { + // 如果编码已存在,重新生成 + $storeCode = $this->generateStoreCode($storeName, $source, true); + } + + // 创建门店记录 + $store = new StoreRepository(); + $store->store_id = $storeId; + $store->store_code = $storeCode; + $store->store_name = $storeName; + $store->store_type = $extraData['store_type'] ?? '线上店'; // 默认线上店 + $store->store_level = $extraData['store_level'] ?? null; + $store->industry_id = $extraData['industry_id'] ?? 'default'; // 默认行业ID,后续可配置 + $store->industry_detail_id = $extraData['industry_detail_id'] ?? null; + $store->store_address = $extraData['store_address'] ?? null; + $store->store_province = $extraData['store_province'] ?? null; + $store->store_city = $extraData['store_city'] ?? null; + $store->store_district = $extraData['store_district'] ?? null; + $store->store_business_area = $extraData['store_business_area'] ?? null; + $store->store_longitude = isset($extraData['store_longitude']) ? (float)$extraData['store_longitude'] : null; + $store->store_latitude = isset($extraData['store_latitude']) ? (float)$extraData['store_latitude'] : null; + $store->store_phone = $extraData['store_phone'] ?? null; + $store->status = 0; // 0-正常 + $store->create_time = $now; + $store->update_time = $now; + + $store->save(); + + LoggerHelper::logBusiness('store_created', [ + 'store_id' => $storeId, + 'store_name' => $storeName, + 'store_code' => $storeCode, + 'store_type' => $store->store_type, + ]); + + return $storeId; + } + + /** + * 生成门店编码 + * + * @param string $storeName 门店名称 + * @param string|null $source 数据源标识 + * @param bool $addTimestamp 是否添加时间戳(用于避免重复) + * @return string 门店编码 + */ + private function generateStoreCode(string $storeName, ?string $source = null, bool $addTimestamp = false): string + { + // 清理门店名称,移除特殊字符,保留中英文和数字 + $cleanedName = preg_replace('/[^\p{L}\p{N}]/u', '', $storeName); + + // 如果名称过长,截取前20个字符 + if (mb_strlen($cleanedName) > 20) { + $cleanedName = mb_substr($cleanedName, 0, 20); + } + + // 如果名称为空,使用默认值 + if (empty($cleanedName)) { + $cleanedName = 'STORE'; + } + + // 生成编码:{source}_{cleaned_name}_{hash} + $hash = substr(md5($storeName . ($source ?? '')), 0, 8); + $code = strtoupper(($source ? $source . '_' : '') . $cleanedName . '_' . $hash); + + // 如果需要添加时间戳(用于避免重复) + if ($addTimestamp) { + $code .= '_' . time(); + } + + return $code; + } + + /** + * 根据门店ID获取门店信息 + * + * @param string $storeId 门店ID + * @return StoreRepository|null + */ + public function getStoreById(string $storeId): ?StoreRepository + { + return $this->storeRepository->newQuery() + ->where('store_id', $storeId) + ->first(); + } +} + diff --git a/app/service/TagInitService.php b/app/service/TagInitService.php new file mode 100644 index 0000000..28592d6 --- /dev/null +++ b/app/service/TagInitService.php @@ -0,0 +1,226 @@ += 5000) + $this->createTagIfNotExists([ + 'tag_id' => UuidGenerator::uuid4()->toString(), + 'tag_code' => 'high_consumer', + 'tag_name' => '高消费用户', + 'category' => '消费能力', + 'rule_type' => 'simple', + 'rule_config' => [ + 'rule_type' => 'simple', + 'conditions' => [ + [ + 'field' => 'total_amount', + 'operator' => '>=', + 'value' => 5000, + ], + ], + 'tag_value' => 'high', + 'confidence' => 1.0, + ], + 'update_frequency' => 'real_time', + 'priority' => 1, + 'description' => '总消费金额大于等于5000的用户', + 'status' => 0, + 'version' => 1, + 'create_time' => $now, + 'update_time' => $now, + ]); + + // 标签2:活跃用户(消费次数 >= 10) + $this->createTagIfNotExists([ + 'tag_id' => UuidGenerator::uuid4()->toString(), + 'tag_code' => 'active_user', + 'tag_name' => '活跃用户', + 'category' => '活跃度', + 'rule_type' => 'simple', + 'rule_config' => [ + 'rule_type' => 'simple', + 'conditions' => [ + [ + 'field' => 'total_count', + 'operator' => '>=', + 'value' => 10, + ], + ], + 'tag_value' => 'active', + 'confidence' => 1.0, + ], + 'update_frequency' => 'real_time', + 'priority' => 2, + 'description' => '消费次数大于等于10次的用户', + 'status' => 0, + 'version' => 1, + 'create_time' => $now, + 'update_time' => $now, + ]); + + // 标签3:中消费用户(总消费金额 >= 1000 且 < 5000) + $this->createTagIfNotExists([ + 'tag_id' => UuidGenerator::uuid4()->toString(), + 'tag_code' => 'medium_consumer', + 'tag_name' => '中消费用户', + 'category' => '消费能力', + 'rule_type' => 'simple', + 'rule_config' => [ + 'rule_type' => 'simple', + 'conditions' => [ + [ + 'field' => 'total_amount', + 'operator' => '>=', + 'value' => 1000, + ], + [ + 'field' => 'total_amount', + 'operator' => '<', + 'value' => 5000, + ], + ], + 'tag_value' => 'medium', + 'confidence' => 1.0, + ], + 'update_frequency' => 'real_time', + 'priority' => 3, + 'description' => '总消费金额在1000-5000之间的用户', + 'status' => 0, + 'version' => 1, + 'create_time' => $now, + 'update_time' => $now, + ]); + + // 标签4:低消费用户(总消费金额 < 1000) + $this->createTagIfNotExists([ + 'tag_id' => UuidGenerator::uuid4()->toString(), + 'tag_code' => 'low_consumer', + 'tag_name' => '低消费用户', + 'category' => '消费能力', + 'rule_type' => 'simple', + 'rule_config' => [ + 'rule_type' => 'simple', + 'conditions' => [ + [ + 'field' => 'total_amount', + 'operator' => '<', + 'value' => 1000, + ], + ], + 'tag_value' => 'low', + 'confidence' => 1.0, + ], + 'update_frequency' => 'real_time', + 'priority' => 4, + 'description' => '总消费金额小于1000的用户', + 'status' => 0, + 'version' => 1, + 'create_time' => $now, + 'update_time' => $now, + ]); + + // 标签5:新用户(消费次数 < 3) + $this->createTagIfNotExists([ + 'tag_id' => UuidGenerator::uuid4()->toString(), + 'tag_code' => 'new_user', + 'tag_name' => '新用户', + 'category' => '活跃度', + 'rule_type' => 'simple', + 'rule_config' => [ + 'rule_type' => 'simple', + 'conditions' => [ + [ + 'field' => 'total_count', + 'operator' => '<', + 'value' => 3, + ], + ], + 'tag_value' => 'new', + 'confidence' => 1.0, + ], + 'update_frequency' => 'real_time', + 'priority' => 5, + 'description' => '消费次数小于3次的用户', + 'status' => 0, + 'version' => 1, + 'create_time' => $now, + 'update_time' => $now, + ]); + + // 标签6:沉睡用户(最后消费时间超过90天) + $this->createTagIfNotExists([ + 'tag_id' => UuidGenerator::uuid4()->toString(), + 'tag_code' => 'dormant_user', + 'tag_name' => '沉睡用户', + 'category' => '活跃度', + 'rule_type' => 'simple', + 'rule_config' => [ + 'rule_type' => 'simple', + 'conditions' => [ + [ + 'field' => 'last_consume_time', + 'operator' => '<', + 'value' => time() - 90 * 24 * 3600, // 90天前的时间戳 + ], + ], + 'tag_value' => 'dormant', + 'confidence' => 1.0, + ], + 'update_frequency' => 'real_time', + 'priority' => 6, + 'description' => '最后消费时间超过90天的用户', + 'status' => 0, + 'version' => 1, + 'create_time' => $now, + 'update_time' => $now, + ]); + } + + /** + * 如果标签不存在则创建 + * + * @param array $tagData + */ + private function createTagIfNotExists(array $tagData): void + { + $existing = $this->tagDefinitionRepository->newQuery() + ->where('tag_code', $tagData['tag_code']) + ->first(); + + if (!$existing) { + $tag = new TagDefinitionRepository(); + foreach ($tagData as $key => $value) { + $tag->$key = $value; + } + $tag->save(); + echo "已创建标签: {$tagData['tag_name']} ({$tagData['tag_code']})\n"; + } else { + echo "标签已存在: {$tagData['tag_name']} ({$tagData['tag_code']})\n"; + } + } +} + diff --git a/app/service/TagRuleEngine/SimpleRuleEngine.php b/app/service/TagRuleEngine/SimpleRuleEngine.php new file mode 100644 index 0000000..98949b2 --- /dev/null +++ b/app/service/TagRuleEngine/SimpleRuleEngine.php @@ -0,0 +1,96 @@ + $ruleConfig 规则配置(从 tag_definitions.rule_config 解析) + * @param array $userData 用户数据(从 user_profile 获取) + * @return array{value: mixed, confidence: float} 返回标签值和置信度 + */ + public function calculate(array $ruleConfig, array $userData): array + { + if (!isset($ruleConfig['rule_type']) || $ruleConfig['rule_type'] !== 'simple') { + throw new \InvalidArgumentException('规则类型必须是 simple'); + } + + if (!isset($ruleConfig['conditions']) || !is_array($ruleConfig['conditions'])) { + throw new \InvalidArgumentException('规则配置中缺少 conditions'); + } + + // 执行所有条件判断 + $allMatch = true; + foreach ($ruleConfig['conditions'] as $condition) { + if (!$this->evaluateCondition($condition, $userData)) { + $allMatch = false; + break; + } + } + + // 如果所有条件都满足,返回标签值 + if ($allMatch) { + // 简单标签:如果满足条件,标签值为 true 或指定的值 + $tagValue = $ruleConfig['tag_value'] ?? true; + $confidence = $ruleConfig['confidence'] ?? 1.0; + + return [ + 'value' => $tagValue, + 'confidence' => (float)$confidence, + ]; + } + + // 条件不满足,返回 false + return [ + 'value' => false, + 'confidence' => 0.0, + ]; + } + + /** + * 评估单个条件 + * + * @param array $condition 条件配置:{field, operator, value} + * @param array $userData 用户数据 + * @return bool + */ + private function evaluateCondition(array $condition, array $userData): bool + { + if (!isset($condition['field']) || !isset($condition['operator']) || !isset($condition['value'])) { + throw new \InvalidArgumentException('条件配置不完整:需要 field, operator, value'); + } + + $field = $condition['field']; + $operator = $condition['operator']; + $expectedValue = $condition['value']; + + // 从用户数据中获取字段值 + if (!isset($userData[$field])) { + // 字段不存在,根据运算符判断(例如 > 0 时,不存在视为 0) + $actualValue = 0; + } else { + $actualValue = $userData[$field]; + } + + // 根据运算符进行比较 + return match ($operator) { + '>' => $actualValue > $expectedValue, + '>=' => $actualValue >= $expectedValue, + '<' => $actualValue < $expectedValue, + '<=' => $actualValue <= $expectedValue, + '=' => $actualValue == $expectedValue, + '!=' => $actualValue != $expectedValue, + 'in' => in_array($actualValue, (array)$expectedValue), + 'not_in' => !in_array($actualValue, (array)$expectedValue), + default => throw new \InvalidArgumentException("不支持的运算符: {$operator}"), + }; + } +} + diff --git a/app/service/TagService.php b/app/service/TagService.php new file mode 100644 index 0000000..af7b0af --- /dev/null +++ b/app/service/TagService.php @@ -0,0 +1,587 @@ +|null $tagIds 要计算的标签ID列表(null 表示计算所有启用且更新频率为 real_time 的标签) + * @return array 返回更新的标签信息 + */ + public function calculateTags(string $userId, ?array $tagIds = null): array + { + // 获取用户数据 + $user = $this->userProfileRepository->findByUserId($userId); + if (!$user) { + throw new \InvalidArgumentException("用户不存在: {$userId}"); + } + + // 准备用户数据(用于规则引擎计算) + $userData = [ + 'total_amount' => (float)($user->total_amount ?? 0), + 'total_count' => (int)($user->total_count ?? 0), + 'last_consume_time' => $user->last_consume_time ? $user->last_consume_time->getTimestamp() : 0, + ]; + + // 获取要计算的标签定义 + $tagDefinitions = $this->getTagDefinitions($tagIds); + + $updatedTags = []; + $now = new \DateTimeImmutable('now'); + + foreach ($tagDefinitions as $tagDef) { + try { + // 解析规则配置 + $ruleConfig = is_string($tagDef->rule_config) + ? json_decode($tagDef->rule_config, true) + : $tagDef->rule_config; + + if (!$ruleConfig) { + continue; + } + + // 根据规则类型选择计算引擎 + if ($ruleConfig['rule_type'] === 'simple') { + $result = $this->ruleEngine->calculate($ruleConfig, $userData); + } else { + // 其他规则类型(pipeline/custom)暂不支持 + continue; + } + + // 获取旧标签值(用于历史记录) + $oldTag = $this->userTagRepository->newQuery() + ->where('user_id', $userId) + ->where('tag_id', $tagDef->tag_id) + ->first(); + + $oldValue = $oldTag ? $oldTag->tag_value : null; + + // 更新或创建标签 + if ($oldTag) { + $oldTag->tag_value = $this->formatTagValue($result['value']); + $oldTag->tag_value_type = $this->getTagValueType($result['value']); + $oldTag->confidence = $result['confidence']; + $oldTag->update_time = $now; + $oldTag->save(); + $userTag = $oldTag; + } else { + $userTag = new UserTagRepository(); + $userTag->user_id = $userId; + $userTag->tag_id = $tagDef->tag_id; + $userTag->tag_value = $this->formatTagValue($result['value']); + $userTag->tag_value_type = $this->getTagValueType($result['value']); + $userTag->confidence = $result['confidence']; + $userTag->effective_time = $now; + $userTag->create_time = $now; + $userTag->update_time = $now; + $userTag->save(); + } + + // 记录标签变更历史(仅当值发生变化时) + if ($oldValue !== $userTag->tag_value) { + $this->recordTagHistory($userId, $tagDef->tag_id, $oldValue, $userTag->tag_value, $now); + } + + $updatedTags[] = [ + 'tag_id' => $tagDef->tag_id, + 'tag_code' => $tagDef->tag_code, + 'tag_name' => $tagDef->tag_name, + 'value' => $userTag->tag_value, + 'confidence' => $userTag->confidence, + ]; + + // 记录标签计算日志 + LoggerHelper::logTagCalculation($userId, $tagDef->tag_id, [ + 'tag_code' => $tagDef->tag_code, + 'value' => $userTag->tag_value, + 'confidence' => $userTag->confidence, + ]); + } catch (\Throwable $e) { + // 记录错误但继续处理其他标签 + LoggerHelper::logError($e, [ + 'user_id' => $userId, + 'tag_id' => $tagDef->tag_id ?? null, + 'tag_code' => $tagDef->tag_code ?? null, + ]); + } + } + + // 更新用户的标签更新时间 + $user->tags_update_time = $now; + $user->save(); + + return $updatedTags; + } + + /** + * 获取标签定义列表 + * + * @param array|null $tagIds + * @return \Illuminate\Database\Eloquent\Collection + */ + private function getTagDefinitions(?array $tagIds = null) + { + $query = $this->tagDefinitionRepository->newQuery() + ->where('status', 0); // 只获取启用的标签 + + if ($tagIds !== null) { + $query->whereIn('tag_id', $tagIds); + } else { + // 默认只计算实时更新的标签 + $query->where('update_frequency', 'real_time'); + } + + return $query->get(); + } + + /** + * 格式化标签值 + * + * @param mixed $value + * @return string + */ + private function formatTagValue($value): string + { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + if (is_array($value) || is_object($value)) { + return json_encode($value); + } + return (string)$value; + } + + /** + * 获取标签值类型 + * + * @param mixed $value + * @return string + */ + private function getTagValueType($value): string + { + if (is_bool($value)) { + return 'boolean'; + } + if (is_int($value) || is_float($value)) { + return 'number'; + } + if (is_array($value) || is_object($value)) { + return 'json'; + } + return 'string'; + } + + /** + * 记录标签变更历史 + * + * @param string $userId + * @param string $tagId + * @param mixed $oldValue + * @param string $newValue + * @param \DateTimeInterface $changeTime + */ + private function recordTagHistory(string $userId, string $tagId, $oldValue, string $newValue, \DateTimeInterface $changeTime): void + { + $history = new TagHistoryRepository(); + $history->history_id = UuidGenerator::uuid4()->toString(); + $history->user_id = $userId; + $history->tag_id = $tagId; + $history->old_value = $oldValue !== null ? (string)$oldValue : null; + $history->new_value = $newValue; + $history->change_reason = 'auto_calculate'; + $history->change_time = $changeTime; + $history->operator = 'system'; + $history->save(); + } + + /** + * 根据标签筛选用户 + * + * @param array $conditions 查询条件数组,每个条件包含: + * - tag_code: 标签编码(必填) + * - operator: 操作符(=, !=, >, >=, <, <=, in, not_in)(必填) + * - value: 标签值(必填) + * @param string $logic 多个条件之间的逻辑关系:AND 或 OR(默认 AND) + * @param int $page 页码(从1开始) + * @param int $pageSize 每页数量 + * @param bool $includeUserInfo 是否包含用户基本信息 + * @return array 返回符合条件的用户列表 + */ + public function filterUsersByTags( + array $conditions, + string $logic = 'AND', + int $page = 1, + int $pageSize = 20, + bool $includeUserInfo = false + ): array { + if (empty($conditions)) { + return [ + 'users' => [], + 'total' => 0, + 'page' => $page, + 'page_size' => $pageSize, + ]; + } + + // 1. 根据 tag_code 获取 tag_id 列表 + $tagCodes = array_column($conditions, 'tag_code'); + $tagDefinitions = $this->tagDefinitionRepository->newQuery() + ->whereIn('tag_code', $tagCodes) + ->get() + ->keyBy('tag_code'); + + $tagIdMap = []; + foreach ($tagDefinitions as $tagDef) { + $tagIdMap[$tagDef->tag_code] = $tagDef->tag_id; + } + + // 验证所有 tag_code 都存在 + $missingTags = array_diff($tagCodes, array_keys($tagIdMap)); + if (!empty($missingTags)) { + throw new \InvalidArgumentException('标签编码不存在: ' . implode(', ', $missingTags)); + } + + // 2. 根据逻辑类型处理查询 + if (strtoupper($logic) === 'OR') { + // OR 逻辑:使用 orWhere,查询满足任一条件的用户 + $query = $this->userTagRepository->newQuery(); + $query->where(function ($q) use ($conditions, $tagIdMap) { + $first = true; + foreach ($conditions as $condition) { + $tagId = $tagIdMap[$condition['tag_code']]; + $operator = $condition['operator'] ?? '='; + $value = $condition['value']; + $formattedValue = $this->formatTagValue($value); + + if ($first) { + $this->applyTagCondition($q, $tagId, $operator, $formattedValue, $value); + $first = false; + } else { + $q->orWhere(function ($subQ) use ($tagId, $operator, $formattedValue, $value) { + $this->applyTagCondition($subQ, $tagId, $operator, $formattedValue, $value); + }); + } + } + }); + + // 分页查询 + $total = $query->count(); + $userTags = $query->skip(($page - 1) * $pageSize) + ->take($pageSize) + ->get(); + + // 提取 user_id 列表 + $userIds = $userTags->pluck('user_id')->unique()->toArray(); + } else { + // AND 逻辑:所有条件都必须满足 + // 由于每个标签是独立的记录,需要分别查询每个条件,然后取交集 + $userIdsSets = []; + foreach ($conditions as $condition) { + $tagId = $tagIdMap[$condition['tag_code']]; + $tagDef = $tagDefinitions->get($condition['tag_code']); + $operator = $condition['operator'] ?? '='; + $value = $condition['value']; + $formattedValue = $this->formatTagValue($value); + + // 为每个条件单独查询满足条件的 user_id(先从标签表查询) + $subQuery = $this->userTagRepository->newQuery(); + $this->applyTagCondition($subQuery, $tagId, $operator, $formattedValue, $value); + $tagUserIds = $subQuery->pluck('user_id')->unique()->toArray(); + + // 如果标签表中没有符合条件的记录,且标签定义中有规则,则基于规则从用户档案表筛选 + // 这样可以处理用户还没有计算标签的情况 + if ($tagDef && $tagDef->rule_type === 'simple') { + $ruleConfig = is_string($tagDef->rule_config) + ? json_decode($tagDef->rule_config, true) + : $tagDef->rule_config; + + if ($ruleConfig && isset($ruleConfig['tag_value']) && $ruleConfig['tag_value'] === $value) { + // 基于规则从用户档案表筛选 + $profileQuery = $this->userProfileRepository->newQuery(); + $this->applyRuleToProfileQuery($profileQuery, $ruleConfig); + $profileUserIds = $profileQuery->pluck('user_id')->unique()->toArray(); + + // 合并标签表和用户档案表的查询结果(去重) + $tagUserIds = array_unique(array_merge($tagUserIds, $profileUserIds)); + } + } + + $userIdsSets[] = $tagUserIds; + } + + // 取交集:所有条件都满足的用户ID + if (empty($userIdsSets)) { + $userIds = []; + } else { + $userIds = $userIdsSets[0]; + for ($i = 1; $i < count($userIdsSets); $i++) { + $userIds = array_intersect($userIds, $userIdsSets[$i]); + } + } + + // 如果没有满足所有条件的用户,直接返回空结果 + if (empty($userIds)) { + return [ + 'users' => [], + 'total' => 0, + 'page' => $page, + 'page_size' => $pageSize, + 'total_pages' => 0, + ]; + } + + // 分页处理 + $total = count($userIds); + $offset = ($page - 1) * $pageSize; + $userIds = array_slice($userIds, $offset, $pageSize); + } + + // 5. 如果需要用户信息,则关联查询 + $users = []; + if ($includeUserInfo && !empty($userIds)) { + $userProfiles = $this->userProfileRepository->newQuery() + ->whereIn('user_id', $userIds) + ->get() + ->keyBy('user_id'); + + foreach ($userIds as $userId) { + $userProfile = $userProfiles->get($userId); + if ($userProfile) { + $users[] = [ + 'user_id' => $userId, + 'name' => $userProfile->name ?? null, + 'phone' => $userProfile->phone ?? null, + 'total_amount' => $userProfile->total_amount ?? 0, + 'total_count' => $userProfile->total_count ?? 0, + 'last_consume_time' => $userProfile->last_consume_time ?? null, + ]; + } else { + $users[] = [ + 'user_id' => $userId, + ]; + } + } + } else { + foreach ($userIds as $userId) { + $users[] = ['user_id' => $userId]; + } + } + + return [ + 'users' => $users, + 'total' => $total, + 'page' => $page, + 'page_size' => $pageSize, + 'total_pages' => (int)ceil($total / $pageSize), + ]; + } + + /** + * 应用标签查询条件到查询构建器 + * + * @param \Illuminate\Database\Eloquent\Builder $query 查询构建器 + * @param string $tagId 标签ID + * @param string $operator 操作符 + * @param string $formattedValue 格式化后的标签值 + * @param mixed $originalValue 原始标签值(用于 in/not_in) + */ + private function applyTagCondition($query, string $tagId, string $operator, string $formattedValue, $originalValue): void + { + $query->where('tag_id', $tagId); + + switch ($operator) { + case '=': + case '==': + $query->where('tag_value', $formattedValue); + break; + case '!=': + case '<>': + $query->where('tag_value', '!=', $formattedValue); + break; + case '>': + $query->where('tag_value', '>', $formattedValue); + break; + case '>=': + $query->where('tag_value', '>=', $formattedValue); + break; + case '<': + $query->where('tag_value', '<', $formattedValue); + break; + case '<=': + $query->where('tag_value', '<=', $formattedValue); + break; + case 'in': + if (!is_array($originalValue)) { + throw new \InvalidArgumentException('in 操作符的值必须是数组'); + } + $query->whereIn('tag_value', array_map([$this, 'formatTagValue'], $originalValue)); + break; + case 'not_in': + if (!is_array($originalValue)) { + throw new \InvalidArgumentException('not_in 操作符的值必须是数组'); + } + $query->whereNotIn('tag_value', array_map([$this, 'formatTagValue'], $originalValue)); + break; + default: + throw new \InvalidArgumentException("不支持的操作符: {$operator}"); + } + } + + /** + * 将标签规则应用到用户档案查询 + * + * @param \Illuminate\Database\Eloquent\Builder $query 查询构建器 + * @param array $ruleConfig 规则配置 + */ + private function applyRuleToProfileQuery($query, array $ruleConfig): void + { + if (!isset($ruleConfig['conditions']) || !is_array($ruleConfig['conditions'])) { + return; + } + + foreach ($ruleConfig['conditions'] as $condition) { + if (!isset($condition['field']) || !isset($condition['operator']) || !isset($condition['value'])) { + continue; + } + + $field = $condition['field']; + $operator = $condition['operator']; + $value = $condition['value']; + + // 将规则条件转换为用户档案表的查询条件 + switch ($operator) { + case '>': + $query->where($field, '>', $value); + break; + case '>=': + $query->where($field, '>=', $value); + break; + case '<': + $query->where($field, '<', $value); + break; + case '<=': + $query->where($field, '<=', $value); + break; + case '=': + case '==': + $query->where($field, $value); + break; + case '!=': + case '<>': + $query->where($field, '!=', $value); + break; + case 'in': + if (is_array($value)) { + $query->whereIn($field, $value); + } + break; + case 'not_in': + if (is_array($value)) { + $query->whereNotIn($field, $value); + } + break; + } + } + + // 只查询未删除的用户 + $query->where('status', 0); + } + + /** + * 获取指定用户的标签列表 + * + * @param string $userId + * @return array> + */ + public function getUserTags(string $userId): array + { + $userTags = $this->userTagRepository->newQuery() + ->where('user_id', $userId) + ->get(); + + $result = []; + foreach ($userTags as $userTag) { + $tagDef = $this->tagDefinitionRepository->newQuery() + ->where('tag_id', $userTag->tag_id) + ->first(); + + $result[] = [ + 'tag_id' => $userTag->tag_id, + 'tag_code' => $tagDef ? $tagDef->tag_code : null, + 'tag_name' => $tagDef ? $tagDef->tag_name : null, + 'category' => $tagDef ? $tagDef->category : null, + 'tag_value' => $userTag->tag_value, + 'tag_value_type' => $userTag->tag_value_type, + 'confidence' => $userTag->confidence, + 'effective_time' => $userTag->effective_time, + 'expire_time' => $userTag->expire_time, + 'update_time' => $userTag->update_time, + ]; + } + return $result; + } + + /** + * 删除用户的指定标签 + * + * @param string $userId 用户ID + * @param string $tagId 标签ID + * @return bool 是否删除成功 + */ + public function deleteUserTag(string $userId, string $tagId): bool + { + $userTag = $this->userTagRepository->newQuery() + ->where('user_id', $userId) + ->where('tag_id', $tagId) + ->first(); + + if (!$userTag) { + return false; + } + + $oldValue = $userTag->tag_value; + + // 删除标签 + $userTag->delete(); + + // 记录历史 + $now = new \DateTimeImmutable('now'); + $this->recordTagHistory($userId, $tagId, $oldValue, null, $now, 'tag_deleted'); + + LoggerHelper::logBusiness('tag_deleted', [ + 'user_id' => $userId, + 'tag_id' => $tagId, + ]); + + return true; + } +} + diff --git a/app/service/TagTaskExecutor.php b/app/service/TagTaskExecutor.php new file mode 100644 index 0000000..9ed8503 --- /dev/null +++ b/app/service/TagTaskExecutor.php @@ -0,0 +1,331 @@ +taskRepository->find($taskId); + + if (!$task) { + throw new \InvalidArgumentException("任务不存在: {$taskId}"); + } + + // 创建执行记录 + $executionId = UuidGenerator::uuid4()->toString(); + $execution = $this->executionRepository->create([ + 'execution_id' => $executionId, + 'task_id' => $taskId, + 'started_at' => new \MongoDB\BSON\UTCDateTime(time() * 1000), + 'status' => 'running', + 'processed_users' => 0, + 'success_count' => 0, + 'error_count' => 0, + ]); + + try { + // 获取用户列表 + $userIds = $this->getUserIds($task); + $totalUsers = count($userIds); + + // 更新任务进度 + $this->updateTaskProgress($taskId, [ + 'total_users' => $totalUsers, + 'processed_users' => 0, + 'success_count' => 0, + 'error_count' => 0, + 'percentage' => 0, + ]); + + // 获取目标标签ID列表 + $targetTagIds = $task->target_tag_ids ?? null; + + // 批量处理用户 + $batchSize = $task->config['batch_size'] ?? 100; + $processedCount = 0; + $successCount = 0; + $errorCount = 0; + + foreach (array_chunk($userIds, $batchSize) as $batch) { + // 检查任务状态(是否被暂停或停止) + if (!$this->checkTaskStatus($taskId)) { + LoggerHelper::logBusiness('tag_task_paused_or_stopped', [ + 'task_id' => $taskId, + 'execution_id' => $executionId, + 'processed' => $processedCount, + ]); + break; + } + + // 批量处理用户 + foreach ($batch as $userId) { + try { + // 计算用户标签 + $this->tagService->calculateTags($userId, $targetTagIds); + $successCount++; + } catch (\Exception $e) { + $errorCount++; + LoggerHelper::logError($e, [ + 'component' => 'TagTaskExecutor', + 'action' => 'calculateTags', + 'task_id' => $taskId, + 'user_id' => $userId, + ]); + + // 根据错误处理策略决定是否继续 + $errorHandling = $task->config['error_handling'] ?? 'skip'; + if ($errorHandling === 'stop') { + throw $e; + } + } + + $processedCount++; + + // 每处理一定数量更新一次进度 + if ($processedCount % 10 === 0) { + $this->updateTaskProgress($taskId, [ + 'processed_users' => $processedCount, + 'success_count' => $successCount, + 'error_count' => $errorCount, + 'percentage' => $totalUsers > 0 ? round(($processedCount / $totalUsers) * 100, 2) : 0, + ]); + + // 更新执行记录 + $this->executionRepository->where('execution_id', $executionId)->update([ + 'processed_users' => $processedCount, + 'success_count' => $successCount, + 'error_count' => $errorCount, + ]); + } + } + } + + // 更新最终进度 + $this->updateTaskProgress($taskId, [ + 'processed_users' => $processedCount, + 'success_count' => $successCount, + 'error_count' => $errorCount, + 'percentage' => $totalUsers > 0 ? round(($processedCount / $totalUsers) * 100, 2) : 100, + ]); + + // 更新执行记录为完成 + $this->executionRepository->where('execution_id', $executionId)->update([ + 'status' => 'completed', + 'finished_at' => new \MongoDB\BSON\UTCDateTime(time() * 1000), + 'processed_users' => $processedCount, + 'success_count' => $successCount, + 'error_count' => $errorCount, + ]); + + // 更新任务统计 + $this->updateTaskStatistics($taskId, $successCount, $errorCount); + + LoggerHelper::logBusiness('tag_task_execution_completed', [ + 'task_id' => $taskId, + 'execution_id' => $executionId, + 'total_users' => $totalUsers, + 'processed' => $processedCount, + 'success' => $successCount, + 'error' => $errorCount, + ]); + + } catch (\Throwable $e) { + // 更新执行记录为失败 + $this->executionRepository->where('execution_id', $executionId)->update([ + 'status' => 'failed', + 'finished_at' => new \MongoDB\BSON\UTCDateTime(time() * 1000), + 'error_message' => $e->getMessage(), + ]); + + // 更新任务状态为错误 + $this->taskRepository->where('task_id', $taskId)->update([ + 'status' => 'error', + 'progress.status' => 'error', + 'progress.last_error' => $e->getMessage(), + ]); + + LoggerHelper::logError($e, [ + 'component' => 'TagTaskExecutor', + 'action' => 'execute', + 'task_id' => $taskId, + 'execution_id' => $executionId, + ]); + + throw $e; + } + } + + /** + * 获取用户ID列表 + * + * @param mixed $task 任务对象 + * @return array 用户ID列表 + */ + private function getUserIds($task): array + { + $userScope = $task->user_scope ?? ['type' => 'all']; + $scopeType = $userScope['type'] ?? 'all'; + + switch ($scopeType) { + case 'all': + // 获取所有用户 + $users = $this->userProfileRepository->newQuery() + ->where('status', 0) // 只获取正常状态的用户 + ->get(); + return $users->pluck('user_id')->toArray(); + + case 'list': + // 指定用户列表 + return $userScope['user_ids'] ?? []; + + case 'filter': + // 按条件筛选 + // 这里可以扩展支持更复杂的筛选条件 + $query = $this->userProfileRepository->newQuery() + ->where('status', 0); + + // 可以添加更多筛选条件 + if (isset($userScope['conditions']) && is_array($userScope['conditions'])) { + foreach ($userScope['conditions'] as $condition) { + $field = $condition['field'] ?? ''; + $operator = $condition['operator'] ?? '='; + $value = $condition['value'] ?? null; + + if (empty($field)) { + continue; + } + + switch ($operator) { + case '>': + $query->where($field, '>', $value); + break; + case '>=': + $query->where($field, '>=', $value); + break; + case '<': + $query->where($field, '<', $value); + break; + case '<=': + $query->where($field, '<=', $value); + break; + case '=': + $query->where($field, $value); + break; + case '!=': + $query->where($field, '!=', $value); + break; + case 'in': + if (is_array($value)) { + $query->whereIn($field, $value); + } + break; + } + } + } + + $users = $query->get(); + return $users->pluck('user_id')->toArray(); + + default: + throw new \InvalidArgumentException("不支持的用户范围类型: {$scopeType}"); + } + } + + /** + * 更新任务进度 + */ + private function updateTaskProgress(string $taskId, array $progress): void + { + $task = $this->taskRepository->find($taskId); + if (!$task) { + return; + } + + $currentProgress = $task->progress ?? []; + $currentProgress = array_merge($currentProgress, $progress); + $currentProgress['status'] = 'running'; + + $this->taskRepository->where('task_id', $taskId)->update([ + 'progress' => $currentProgress, + 'updated_at' => new \MongoDB\BSON\UTCDateTime(time() * 1000), + ]); + } + + /** + * 更新任务统计 + */ + private function updateTaskStatistics(string $taskId, int $successCount, int $errorCount): void + { + $task = $this->taskRepository->find($taskId); + if (!$task) { + return; + } + + $statistics = $task->statistics ?? []; + $statistics['total_executions'] = ($statistics['total_executions'] ?? 0) + 1; + $statistics['success_executions'] = ($statistics['success_executions'] ?? 0) + ($errorCount === 0 ? 1 : 0); + $statistics['failed_executions'] = ($statistics['failed_executions'] ?? 0) + ($errorCount > 0 ? 1 : 0); + $statistics['last_run_time'] = new \MongoDB\BSON\UTCDateTime(time() * 1000); + + $this->taskRepository->where('task_id', $taskId)->update([ + 'statistics' => $statistics, + 'updated_at' => new \MongoDB\BSON\UTCDateTime(time() * 1000), + ]); + } + + /** + * 检查任务状态 + */ + private function checkTaskStatus(string $taskId): bool + { + // 检查Redis标志 + if (RedisHelper::exists("tag_task:{$taskId}:pause")) { + return false; + } + if (RedisHelper::exists("tag_task:{$taskId}:stop")) { + return false; + } + + // 检查数据库状态 + $task = $this->taskRepository->find($taskId); + if ($task && in_array($task->status, ['paused', 'stopped', 'error'])) { + return false; + } + + return true; + } +} + diff --git a/app/service/TagTaskService.php b/app/service/TagTaskService.php new file mode 100644 index 0000000..d628f38 --- /dev/null +++ b/app/service/TagTaskService.php @@ -0,0 +1,283 @@ + $taskData 任务数据 + * @return array 创建的任务信息 + */ + public function createTask(array $taskData): array + { + $taskId = UuidGenerator::uuid4()->toString(); + + $task = [ + 'task_id' => $taskId, + 'name' => $taskData['name'] ?? '未命名标签任务', + 'description' => $taskData['description'] ?? '', + 'task_type' => $taskData['task_type'] ?? 'full', + 'target_tag_ids' => $taskData['target_tag_ids'] ?? [], + 'user_scope' => $taskData['user_scope'] ?? ['type' => 'all'], + 'schedule' => $taskData['schedule'] ?? [ + 'enabled' => false, + 'cron' => null, + ], + 'config' => $taskData['config'] ?? [ + 'concurrency' => 10, + 'batch_size' => 100, + 'error_handling' => 'skip', + ], + 'status' => 'pending', + 'progress' => [ + 'total_users' => 0, + 'processed_users' => 0, + 'success_count' => 0, + 'error_count' => 0, + 'percentage' => 0, + ], + 'statistics' => [ + 'total_executions' => 0, + 'success_executions' => 0, + 'failed_executions' => 0, + 'last_run_time' => null, + ], + 'created_by' => $taskData['created_by'] ?? 'system', + 'created_at' => new \MongoDB\BSON\UTCDateTime(time() * 1000), + 'updated_at' => new \MongoDB\BSON\UTCDateTime(time() * 1000), + ]; + + $this->taskRepository->create($task); + + LoggerHelper::logBusiness('tag_task_created', [ + 'task_id' => $taskId, + 'task_name' => $task['name'], + ]); + + return $task; + } + + /** + * 更新任务 + */ + public function updateTask(string $taskId, array $taskData): bool + { + $task = $this->taskRepository->find($taskId); + + if (!$task) { + throw new \InvalidArgumentException("任务不存在: {$taskId}"); + } + + if ($task->status === 'running') { + $allowedFields = ['name', 'description', 'schedule']; + $taskData = array_intersect_key($taskData, array_flip($allowedFields)); + } + + $taskData['updated_at'] = new \MongoDB\BSON\UTCDateTime(time() * 1000); + + return $this->taskRepository->where('task_id', $taskId)->update($taskData) > 0; + } + + /** + * 删除任务 + */ + public function deleteTask(string $taskId): bool + { + $task = $this->taskRepository->find($taskId); + + if (!$task) { + throw new \InvalidArgumentException("任务不存在: {$taskId}"); + } + + if ($task->status === 'running') { + $this->stopTask($taskId); + } + + return $this->taskRepository->where('task_id', $taskId)->delete() > 0; + } + + /** + * 启动任务 + */ + public function startTask(string $taskId): bool + { + $task = $this->taskRepository->find($taskId); + + if (!$task) { + throw new \InvalidArgumentException("任务不存在: {$taskId}"); + } + + if ($task->status === 'running') { + throw new \RuntimeException("任务已在运行中: {$taskId}"); + } + + $this->taskRepository->where('task_id', $taskId)->update([ + 'status' => 'running', + 'updated_at' => new \MongoDB\BSON\UTCDateTime(time() * 1000), + ]); + + // 设置Redis标志,通知调度器启动任务 + RedisHelper::set("tag_task:{$taskId}:start", '1', 3600); + + LoggerHelper::logBusiness('tag_task_started', [ + 'task_id' => $taskId, + ]); + + return true; + } + + /** + * 暂停任务 + */ + public function pauseTask(string $taskId): bool + { + $task = $this->taskRepository->find($taskId); + + if (!$task) { + throw new \InvalidArgumentException("任务不存在: {$taskId}"); + } + + if ($task->status !== 'running') { + throw new \RuntimeException("任务未在运行中: {$taskId}"); + } + + $this->taskRepository->where('task_id', $taskId)->update([ + 'status' => 'paused', + 'updated_at' => new \MongoDB\BSON\UTCDateTime(time() * 1000), + ]); + + RedisHelper::set("tag_task:{$taskId}:pause", '1', 3600); + + return true; + } + + /** + * 停止任务 + */ + public function stopTask(string $taskId): bool + { + $task = $this->taskRepository->find($taskId); + + if (!$task) { + throw new \InvalidArgumentException("任务不存在: {$taskId}"); + } + + $this->taskRepository->where('task_id', $taskId)->update([ + 'status' => 'stopped', + 'updated_at' => new \MongoDB\BSON\UTCDateTime(time() * 1000), + ]); + + RedisHelper::set("tag_task:{$taskId}:stop", '1', 3600); + + return true; + } + + /** + * 获取任务列表 + */ + public function getTaskList(array $filters = [], int $page = 1, int $pageSize = 20): array + { + $query = $this->taskRepository->query(); + + if (isset($filters['status'])) { + $query->where('status', $filters['status']); + } + if (isset($filters['task_type'])) { + $query->where('task_type', $filters['task_type']); + } + if (isset($filters['name'])) { + $query->where('name', 'like', '%' . $filters['name'] . '%'); + } + + $total = $query->count(); + $tasks = $query->orderBy('created_at', 'desc') + ->skip(($page - 1) * $pageSize) + ->take($pageSize) + ->get() + ->toArray(); + + return [ + 'tasks' => $tasks, + 'total' => $total, + 'page' => $page, + 'page_size' => $pageSize, + 'total_pages' => ceil($total / $pageSize), + ]; + } + + /** + * 获取任务详情 + */ + public function getTask(string $taskId): ?array + { + $task = $this->taskRepository->find($taskId); + return $task ? $task->toArray() : null; + } + + /** + * 获取任务执行记录 + */ + public function getExecutions(string $taskId, int $page = 1, int $pageSize = 20): array + { + $query = $this->executionRepository->query()->where('task_id', $taskId); + + $total = $query->count(); + $executions = $query->orderBy('started_at', 'desc') + ->skip(($page - 1) * $pageSize) + ->take($pageSize) + ->get() + ->toArray(); + + return [ + 'executions' => $executions, + 'total' => $total, + 'page' => $page, + 'page_size' => $pageSize, + 'total_pages' => ceil($total / $pageSize), + ]; + } + + /** + * 执行任务(供调度器调用) + */ + public function executeTask(string $taskId): void + { + $executor = new \app\service\TagTaskExecutor( + $this->taskRepository, + $this->executionRepository, + $this->userProfileRepository, + new \app\repository\TagDefinitionRepository(), + $this->tagService + ); + + $executor->execute($taskId); + } +} + diff --git a/app/service/UserPhoneService.php b/app/service/UserPhoneService.php new file mode 100644 index 0000000..69c0599 --- /dev/null +++ b/app/service/UserPhoneService.php @@ -0,0 +1,537 @@ + $options 可选参数 + * - type: 手机号类型(personal/work/backup/other) + * - is_verified: 是否已验证 + * - effective_time: 生效时间(默认当前时间) + * - expire_time: 失效时间(默认null,表示当前有效) + * - source: 来源(registration/update/manual/import) + * @return string 关联ID + * @throws \InvalidArgumentException + */ + public function addPhoneToUser(string $userId, string $phoneNumber, array $options = []): string + { + \Workerman\Worker::safeEcho("\n"); + \Workerman\Worker::safeEcho("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"); + \Workerman\Worker::safeEcho("[UserPhoneService::addPhoneToUser] 【断点1-方法入口】开始执行\n"); + \Workerman\Worker::safeEcho("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"); + \Workerman\Worker::safeEcho("【断点1】原始传入参数:\n"); + \Workerman\Worker::safeEcho(" - userId: {$userId}\n"); + \Workerman\Worker::safeEcho(" - phoneNumber: {$phoneNumber}\n"); + \Workerman\Worker::safeEcho(" - options: " . json_encode($options, JSON_UNESCAPED_UNICODE) . "\n"); + + \Workerman\Worker::safeEcho("\n【断点2-参数处理】开始处理参数\n"); + $phoneNumber = trim($phoneNumber); + \Workerman\Worker::safeEcho(" - trim后phoneNumber: {$phoneNumber}\n"); + + // 检查手机号是否为空 + if (empty($phoneNumber)) { + \Workerman\Worker::safeEcho("【断点2】❌ 手机号为空,抛出异常\n"); + throw new \InvalidArgumentException('手机号不能为空'); + } + + // 过滤非数字字符 + $originalPhone = $phoneNumber; + \Workerman\Worker::safeEcho("\n【断点3-过滤处理】开始过滤非数字字符\n"); + $phoneNumber = $this->filterPhoneNumber($phoneNumber); + \Workerman\Worker::safeEcho(" - 原始手机号: {$originalPhone}\n"); + \Workerman\Worker::safeEcho(" - 过滤后手机号: {$phoneNumber}\n"); + \Workerman\Worker::safeEcho(" - 过滤后长度: " . strlen($phoneNumber) . "\n"); + + // 检查过滤后是否为空 + if (empty($phoneNumber)) { + \Workerman\Worker::safeEcho("【断点3】❌ 手机号过滤后为空,抛出异常\n"); + throw new \InvalidArgumentException("手机号过滤后为空: {$originalPhone}"); + } + + \Workerman\Worker::safeEcho("\n【断点4-格式验证】开始验证手机号格式\n"); + // 验证手机号格式(过滤后的手机号) + $isValid = $this->validatePhoneNumber($phoneNumber); + \Workerman\Worker::safeEcho(" - 验证结果: " . ($isValid ? '通过 ✓' : '失败 ✗') . "\n"); + if (!$isValid) { + \Workerman\Worker::safeEcho("【断点4】❌ 手机号格式验证失败,抛出异常\n"); + \Workerman\Worker::safeEcho(" - 验证规则: /^1[3-9]\\d{9}$/\n"); + \Workerman\Worker::safeEcho(" - 实际值: {$phoneNumber}\n"); + \Workerman\Worker::safeEcho(" - 长度: " . strlen($phoneNumber) . "\n"); + throw new \InvalidArgumentException("手机号格式不正确: {$originalPhone} -> {$phoneNumber} (长度: " . strlen($phoneNumber) . ")"); + } + \Workerman\Worker::safeEcho("【断点4】✓ 格式验证通过\n"); + + \Workerman\Worker::safeEcho("\n【断点5-哈希计算】开始计算手机号哈希\n"); + $phoneHash = EncryptionHelper::hash($phoneNumber); + $now = new \DateTimeImmutable('now'); + $effectiveTime = $options['effective_time'] ?? $now; + \Workerman\Worker::safeEcho(" - phoneHash: {$phoneHash}\n"); + \Workerman\Worker::safeEcho(" - effectiveTime: " . $effectiveTime->format('Y-m-d H:i:s') . "\n"); + + \Workerman\Worker::safeEcho("\n【断点6-冲突检查】检查是否存在冲突关联\n"); + // 检查该手机号在effectiveTime是否已有有效关联 + // 使用effectiveTime作为查询时间点,查找是否有冲突的关联 + $existingActive = $this->phoneRelationRepository->findActiveByPhoneHash($phoneHash, $effectiveTime); + \Workerman\Worker::safeEcho(" - 查询结果: " . ($existingActive ? "找到冲突关联 (user_id: {$existingActive->user_id})" : "无冲突") . "\n"); + + if ($existingActive && $existingActive->user_id !== $userId) { + \Workerman\Worker::safeEcho("【断点6】⚠️ 发现冲突,需要失效旧关联\n"); + // 如果手机号在effectiveTime已被其他用户使用,需要先失效旧关联 + // 过期时间设置为新关联的effectiveTime(保证时间连续,避免间隙) + $existingActive->expire_time = $effectiveTime; + $existingActive->is_active = false; + $existingActive->update_time = $now; + $existingActive->save(); + \Workerman\Worker::safeEcho(" - 旧关联已失效,expire_time: " . $effectiveTime->format('Y-m-d H:i:s') . "\n"); + + LoggerHelper::logBusiness('phone_relation_expired_due_to_conflict', [ + 'phone_number' => $phoneNumber, + 'old_user_id' => $existingActive->user_id, + 'new_user_id' => $userId, + 'expire_time' => $effectiveTime->format('Y-m-d H:i:s'), + 'effective_time' => $effectiveTime->format('Y-m-d H:i:s'), + ]); + } else { + \Workerman\Worker::safeEcho("【断点6】✓ 无冲突,继续创建新关联\n"); + } + + \Workerman\Worker::safeEcho("\n【断点7-数据准备】开始准备要保存的数据\n"); + + try { + \Workerman\Worker::safeEcho(" [7.1] 创建 UserPhoneRelationRepository 对象...\n"); + // 创建新关联 + $relation = new UserPhoneRelationRepository(); + \Workerman\Worker::safeEcho(" [7.1] ✓ 对象创建成功\n"); + + \Workerman\Worker::safeEcho(" [7.2] 设置 relation_id...\n"); + $relation->relation_id = UuidGenerator::uuid4()->toString(); + \Workerman\Worker::safeEcho(" [7.2] ✓ relation_id = {$relation->relation_id}\n"); + + \Workerman\Worker::safeEcho(" [7.3] 设置 phone_number...\n"); + $relation->phone_number = $phoneNumber; + \Workerman\Worker::safeEcho(" [7.3] ✓ phone_number = {$relation->phone_number}\n"); + + \Workerman\Worker::safeEcho(" [7.4] 设置 phone_hash...\n"); + $relation->phone_hash = $phoneHash; + \Workerman\Worker::safeEcho(" [7.4] ✓ phone_hash = {$relation->phone_hash}\n"); + + \Workerman\Worker::safeEcho(" [7.5] 设置 user_id...\n"); + $relation->user_id = $userId; + \Workerman\Worker::safeEcho(" [7.5] ✓ user_id = {$relation->user_id}\n"); + + \Workerman\Worker::safeEcho(" [7.6] 设置 effective_time...\n"); + \Workerman\Worker::safeEcho(" - effectiveTime类型: " . get_class($effectiveTime) . "\n"); + \Workerman\Worker::safeEcho(" - effectiveTime值: " . $effectiveTime->format('Y-m-d H:i:s') . "\n"); + $relation->effective_time = $effectiveTime; + \Workerman\Worker::safeEcho(" [7.6] ✓ effective_time 设置完成\n"); + + \Workerman\Worker::safeEcho(" [7.7] 设置 expire_time...\n"); + $expireTimeValue = $options['expire_time'] ?? null; + \Workerman\Worker::safeEcho(" - expireTime值: " . ($expireTimeValue ? (is_object($expireTimeValue) ? $expireTimeValue->format('Y-m-d H:i:s') : $expireTimeValue) : 'null') . "\n"); + $relation->expire_time = $expireTimeValue; + \Workerman\Worker::safeEcho(" [7.7] ✓ expire_time 设置完成\n"); + + \Workerman\Worker::safeEcho(" [7.8] 设置 is_active...\n"); + // 如果 expire_time 为 null 或不存在,则 is_active 为 true + $isActiveValue = ($options['expire_time'] ?? null) === null; + \Workerman\Worker::safeEcho(" - isActive值: " . ($isActiveValue ? 'true' : 'false') . "\n"); + $relation->is_active = $isActiveValue; + \Workerman\Worker::safeEcho(" [7.8] ✓ is_active 设置完成\n"); + + \Workerman\Worker::safeEcho(" [7.9] 设置 type...\n"); + $typeValue = $options['type'] ?? 'personal'; + \Workerman\Worker::safeEcho(" - type值: {$typeValue}\n"); + $relation->type = $typeValue; + \Workerman\Worker::safeEcho(" [7.9] ✓ type 设置完成\n"); + + \Workerman\Worker::safeEcho(" [7.10] 设置 is_verified...\n"); + $isVerifiedValue = $options['is_verified'] ?? false; + \Workerman\Worker::safeEcho(" - isVerified值: " . ($isVerifiedValue ? 'true' : 'false') . "\n"); + $relation->is_verified = $isVerifiedValue; + \Workerman\Worker::safeEcho(" [7.10] ✓ is_verified 设置完成\n"); + + \Workerman\Worker::safeEcho(" [7.11] 设置 source...\n"); + $sourceValue = $options['source'] ?? 'manual'; + \Workerman\Worker::safeEcho(" - source值: {$sourceValue}\n"); + $relation->source = $sourceValue; + \Workerman\Worker::safeEcho(" [7.11] ✓ source 设置完成\n"); + + \Workerman\Worker::safeEcho(" [7.12] 设置 create_time...\n"); + \Workerman\Worker::safeEcho(" - now类型: " . get_class($now) . "\n"); + \Workerman\Worker::safeEcho(" - now值: " . $now->format('Y-m-d H:i:s') . "\n"); + $relation->create_time = $now; + \Workerman\Worker::safeEcho(" [7.12] ✓ create_time 设置完成\n"); + + \Workerman\Worker::safeEcho(" [7.13] 设置 update_time...\n"); + $relation->update_time = $now; + \Workerman\Worker::safeEcho(" [7.13] ✓ update_time 设置完成\n"); + + \Workerman\Worker::safeEcho(" [7.14] ✓ 所有属性设置完成,准备打印数据详情\n"); + + } catch (\Throwable $e) { + \Workerman\Worker::safeEcho("\n【断点7】❌ 数据准备过程中发生异常!\n"); + \Workerman\Worker::safeEcho(" - 错误信息: " . $e->getMessage() . "\n"); + \Workerman\Worker::safeEcho(" - 错误类型: " . get_class($e) . "\n"); + \Workerman\Worker::safeEcho(" - 文件: " . $e->getFile() . ":" . $e->getLine() . "\n"); + \Workerman\Worker::safeEcho(" - 堆栈跟踪:\n"); + $trace = $e->getTraceAsString(); + $traceLines = explode("\n", $trace); + foreach (array_slice($traceLines, 0, 10) as $line) { + \Workerman\Worker::safeEcho(" " . $line . "\n"); + } + throw $e; + } + + \Workerman\Worker::safeEcho("【断点7】准备保存的数据详情:\n"); + \Workerman\Worker::safeEcho(" - relation_id: {$relation->relation_id}\n"); + \Workerman\Worker::safeEcho(" - phone_number: {$relation->phone_number}\n"); + \Workerman\Worker::safeEcho(" - phone_hash: {$relation->phone_hash}\n"); + \Workerman\Worker::safeEcho(" - user_id: {$relation->user_id}\n"); + \Workerman\Worker::safeEcho(" - effective_time: " . ($relation->effective_time ? $relation->effective_time->format('Y-m-d H:i:s') : 'null') . "\n"); + \Workerman\Worker::safeEcho(" - expire_time: " . ($relation->expire_time ? $relation->expire_time->format('Y-m-d H:i:s') : 'null') . "\n"); + \Workerman\Worker::safeEcho(" - is_active: " . ($relation->is_active ? 'true' : 'false') . "\n"); + \Workerman\Worker::safeEcho(" - type: {$relation->type}\n"); + \Workerman\Worker::safeEcho(" - is_verified: " . ($relation->is_verified ? 'true' : 'false') . "\n"); + \Workerman\Worker::safeEcho(" - source: {$relation->source}\n"); + \Workerman\Worker::safeEcho(" - create_time: " . ($relation->create_time ? $relation->create_time->format('Y-m-d H:i:s') : 'null') . "\n"); + \Workerman\Worker::safeEcho(" - update_time: " . ($relation->update_time ? $relation->update_time->format('Y-m-d H:i:s') : 'null') . "\n"); + + \Workerman\Worker::safeEcho("\n【断点8-数据库配置检查】检查数据库配置\n"); + \Workerman\Worker::safeEcho("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"); + + try { + // 获取表名 + $tableName = $relation->getTable(); + \Workerman\Worker::safeEcho(" ✓ 目标表名: {$tableName}\n"); + + // 获取连接名 + $connectionName = $relation->getConnectionName(); + \Workerman\Worker::safeEcho(" ✓ 数据库连接名: {$connectionName}\n"); + + // 获取连接对象 + $connection = $relation->getConnection(); + \Workerman\Worker::safeEcho(" ✓ 连接对象获取成功\n"); + + // 获取数据库名 + $databaseName = $connection->getDatabaseName(); + \Workerman\Worker::safeEcho(" ✓ 数据库名: {$databaseName}\n"); + + // 获取配置信息 + $config = config('database.connections.' . $connectionName, []); + \Workerman\Worker::safeEcho("\n 数据库配置详情:\n"); + \Workerman\Worker::safeEcho(" - driver: " . ($config['driver'] ?? 'unknown') . "\n"); + \Workerman\Worker::safeEcho(" - dsn: " . ($config['dsn'] ?? 'unknown') . "\n"); + \Workerman\Worker::safeEcho(" - database: " . ($config['database'] ?? 'unknown') . "\n"); + \Workerman\Worker::safeEcho(" - username: " . (isset($config['username']) ? $config['username'] : 'null') . "\n"); + \Workerman\Worker::safeEcho(" - has_password: " . (isset($config['password']) ? 'yes' : 'no') . "\n"); + + // 尝试获取MongoDB客户端信息 + try { + $mongoClient = $connection->getMongoClient(); + if ($mongoClient) { + \Workerman\Worker::safeEcho(" - MongoDB客户端: 已获取 ✓\n"); + } + } catch (\Throwable $e) { + \Workerman\Worker::safeEcho(" - MongoDB客户端获取失败: " . $e->getMessage() . "\n"); + } + + // 测试连接 + try { + $testCollection = $connection->getCollection($tableName); + \Workerman\Worker::safeEcho(" - 集合对象获取: 成功 ✓\n"); + \Workerman\Worker::safeEcho(" - 集合名: {$tableName}\n"); + } catch (\Throwable $e) { + \Workerman\Worker::safeEcho(" - 集合对象获取失败: " . $e->getMessage() . "\n"); + } + + \Workerman\Worker::safeEcho("\n 最终写入目标:\n"); + \Workerman\Worker::safeEcho(" - 数据库: {$databaseName}\n"); + \Workerman\Worker::safeEcho(" - 集合: {$tableName}\n"); + \Workerman\Worker::safeEcho(" - 连接: {$connectionName}\n"); + \Workerman\Worker::safeEcho(" - 连接状态: 已连接 ✓\n"); + + } catch (\Throwable $e) { + \Workerman\Worker::safeEcho(" ❌ 数据库配置检查失败!\n"); + \Workerman\Worker::safeEcho(" - 错误信息: " . $e->getMessage() . "\n"); + \Workerman\Worker::safeEcho(" - 错误类型: " . get_class($e) . "\n"); + \Workerman\Worker::safeEcho(" - 文件: " . $e->getFile() . ":" . $e->getLine() . "\n"); + \Workerman\Worker::safeEcho(" - 堆栈: " . $e->getTraceAsString() . "\n"); + } + + \Workerman\Worker::safeEcho("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"); + + \Workerman\Worker::safeEcho("\n【断点9-执行保存】开始执行 save() 操作\n"); + \Workerman\Worker::safeEcho(" - 调用: \$relation->save()\n"); + + // 执行保存 + try { + $saveResult = $relation->save(); + \Workerman\Worker::safeEcho("【断点9】save() 执行完成\n"); + \Workerman\Worker::safeEcho(" - save() 返回值: " . ($saveResult ? 'true ✓' : 'false ✗') . "\n"); + + if (!$saveResult) { + \Workerman\Worker::safeEcho(" - ❌ 警告:save() 返回 false,数据可能未保存\n"); + } + + \Workerman\Worker::safeEcho("\n【断点10-保存后验证】验证数据是否真的写入数据库\n"); + \Workerman\Worker::safeEcho(" - 查询条件: relation_id = {$relation->relation_id}\n"); + + // 验证是否真的保存成功(尝试查询) + $savedRelation = $this->phoneRelationRepository->findByRelationId($relation->relation_id); + if ($savedRelation) { + \Workerman\Worker::safeEcho(" - ✅ 验证成功:查询到保存的数据\n"); + \Workerman\Worker::safeEcho(" - 查询到的 relation_id: {$savedRelation->relation_id}\n"); + \Workerman\Worker::safeEcho(" - 查询到的 user_id: {$savedRelation->user_id}\n"); + \Workerman\Worker::safeEcho(" - 查询到的 phone_number: {$savedRelation->phone_number}\n"); + } else { + \Workerman\Worker::safeEcho(" - ❌ 验证失败:save()返回true但查询不到数据\n"); + \Workerman\Worker::safeEcho(" - 可能原因:\n"); + \Workerman\Worker::safeEcho(" 1. MongoDB写入确认问题(w=0模式)\n"); + \Workerman\Worker::safeEcho(" 2. 数据库连接问题\n"); + \Workerman\Worker::safeEcho(" 3. 事务未提交\n"); + \Workerman\Worker::safeEcho(" 4. 写入延迟\n"); + } + } catch (\Throwable $e) { + \Workerman\Worker::safeEcho("\n【断点9】❌ 保存过程中发生异常!\n"); + \Workerman\Worker::safeEcho(" - 错误信息: " . $e->getMessage() . "\n"); + \Workerman\Worker::safeEcho(" - 错误类型: " . get_class($e) . "\n"); + \Workerman\Worker::safeEcho(" - 文件: " . $e->getFile() . ":" . $e->getLine() . "\n"); + \Workerman\Worker::safeEcho(" - 堆栈跟踪:\n"); + $trace = $e->getTraceAsString(); + $traceLines = explode("\n", $trace); + foreach (array_slice($traceLines, 0, 5) as $line) { + \Workerman\Worker::safeEcho(" " . $line . "\n"); + } + throw $e; + } + + \Workerman\Worker::safeEcho("\n【断点11-日志记录】记录业务日志\n"); + + LoggerHelper::logBusiness('phone_relation_created', [ + 'relation_id' => $relation->relation_id, + 'user_id' => $userId, + 'phone_number' => $phoneNumber, + 'type' => $relation->type, + 'effective_time' => $effectiveTime->format('Y-m-d H:i:s'), + ]); + \Workerman\Worker::safeEcho("【断点11】✓ 业务日志已记录\n"); + + \Workerman\Worker::safeEcho("\n【断点12-方法返回】准备返回结果\n"); + \Workerman\Worker::safeEcho(" - 返回 relation_id: {$relation->relation_id}\n"); + \Workerman\Worker::safeEcho("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"); + \Workerman\Worker::safeEcho("[UserPhoneService::addPhoneToUser] ✅ 方法执行完成\n"); + \Workerman\Worker::safeEcho("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"); + + return $relation->relation_id; + } + + /** + * 移除用户的手机号(失效关联) + * + * @param string $userId 用户ID + * @param string $phoneNumber 手机号 + * @param \DateTimeInterface|null $expireTime 过期时间(默认当前时间) + * @return bool 是否成功 + */ + public function removePhoneFromUser(string $userId, string $phoneNumber, ?\DateTimeInterface $expireTime = null): bool + { + // 过滤非数字字符 + $phoneNumber = $this->filterPhoneNumber(trim($phoneNumber)); + if (empty($phoneNumber)) { + return false; + } + + $phoneHash = EncryptionHelper::hash($phoneNumber); + $expireTime = $expireTime ?? new \DateTimeImmutable('now'); + + $relations = $this->phoneRelationRepository->newQuery() + ->where('phone_hash', $phoneHash) + ->where('user_id', $userId) + ->where('is_active', true) + ->where(function($q) use ($expireTime) { + $q->whereNull('expire_time') + ->orWhere('expire_time', '>', $expireTime); + }) + ->get(); + + if ($relations->isEmpty()) { + return false; + } + + foreach ($relations as $relation) { + $relation->expire_time = $expireTime; + $relation->is_active = false; + $relation->update_time = new \DateTimeImmutable('now'); + $relation->save(); + } + + LoggerHelper::logBusiness('phone_relation_removed', [ + 'user_id' => $userId, + 'phone_number' => $phoneNumber, + 'expire_time' => $expireTime->format('Y-m-d H:i:s'), + ]); + + return true; + } + + /** + * 根据手机号查找当前用户 + * + * @param string $phoneNumber 手机号 + * @param \DateTimeInterface|null $atTime 查询时间点(默认为当前时间) + * @return string|null 用户ID + */ + public function findUserByPhone(string $phoneNumber, ?\DateTimeInterface $atTime = null): ?string + { + // 过滤非数字字符 + $phoneNumber = $this->filterPhoneNumber(trim($phoneNumber)); + if (empty($phoneNumber)) { + return null; + } + + $phoneHash = EncryptionHelper::hash($phoneNumber); + $relation = $this->phoneRelationRepository->findActiveByPhoneHash($phoneHash, $atTime); + + return $relation ? $relation->user_id : null; + } + + /** + * 获取用户的所有手机号 + * + * @param string $userId 用户ID + * @param bool $includeHistory 是否包含历史记录 + * @return array> 手机号列表 + */ + public function getUserPhones(string $userId, bool $includeHistory = false): array + { + $relations = $this->phoneRelationRepository->findByUserId($userId, $includeHistory); + + return array_map(function($relation) { + return [ + 'phone_number' => $relation->phone_number, + 'type' => $relation->type, + 'is_verified' => $relation->is_verified, + 'effective_time' => $relation->effective_time, + 'expire_time' => $relation->expire_time, + 'is_active' => $relation->is_active, + 'source' => $relation->source, + ]; + }, $relations); + } + + /** + * 获取用户的所有手机号号码(仅号码列表) + * + * @param string $userId 用户ID + * @param bool $includeHistory 是否包含历史记录 + * @return array 手机号列表 + */ + public function getUserPhoneNumbers(string $userId, bool $includeHistory = false): array + { + $relations = $this->phoneRelationRepository->findByUserId($userId, $includeHistory); + + return array_map(function($relation) { + return $relation->phone_number; + }, $relations); + } + + /** + * 获取手机号的历史关联记录 + * + * @param string $phoneNumber 手机号 + * @return array> 历史关联记录 + */ + public function getPhoneHistory(string $phoneNumber): array + { + // 过滤非数字字符 + $phoneNumber = $this->filterPhoneNumber(trim($phoneNumber)); + if (empty($phoneNumber)) { + return []; + } + + $phoneHash = EncryptionHelper::hash($phoneNumber); + $relations = $this->phoneRelationRepository->findHistoryByPhoneHash($phoneHash); + + return array_map(function($relation) { + return [ + 'relation_id' => $relation->relation_id, + 'user_id' => $relation->user_id, + 'effective_time' => $relation->effective_time, + 'expire_time' => $relation->expire_time, + 'is_active' => $relation->is_active, + 'type' => $relation->type, + 'is_verified' => $relation->is_verified, + 'source' => $relation->source, + ]; + }, $relations); + } + + /** + * 检查手机号是否已被使用(当前有效) + * + * @param string $phoneNumber 手机号 + * @return bool + */ + public function isPhoneInUse(string $phoneNumber): bool + { + // 过滤非数字字符 + $phoneNumber = $this->filterPhoneNumber(trim($phoneNumber)); + if (empty($phoneNumber)) { + return false; + } + + $phoneHash = EncryptionHelper::hash($phoneNumber); + $relation = $this->phoneRelationRepository->findActiveByPhoneHash($phoneHash); + + return $relation !== null; + } + + /** + * 过滤手机号中的非数字字符 + * + * @param string $phoneNumber 原始手机号 + * @return string 过滤后的手机号(只包含数字) + */ + protected function filterPhoneNumber(string $phoneNumber): string + { + // 移除所有非数字字符 + return preg_replace('/\D/', '', $phoneNumber); + } + + /** + * 验证手机号格式(内部使用,假设已经过滤过非数字字符) + * + * @param string $phoneNumber 已过滤的手机号(只包含数字) + * @return bool + */ + protected function validatePhoneNumber(string $phoneNumber): bool + { + // 中国大陆手机号:11位数字,以1开头 + return preg_match('/^1[3-9]\d{9}$/', $phoneNumber) === 1; + } +} + diff --git a/app/service/UserService.php b/app/service/UserService.php new file mode 100644 index 0000000..a02c481 --- /dev/null +++ b/app/service/UserService.php @@ -0,0 +1,395 @@ + $data 用户数据 + * @return array 创建的用户信息 + * @throws \InvalidArgumentException + */ + public function createUser(array $data): array + { + // 验证必填字段 + if (empty($data['id_card'])) { + throw new \InvalidArgumentException('身份证号不能为空'); + } + + $idCard = trim($data['id_card']); + $idCardType = $data['id_card_type'] ?? '身份证'; + + // 验证身份证格式(简单验证) + if ($idCardType === '身份证' && !$this->validateIdCard($idCard)) { + throw new \InvalidArgumentException('身份证号格式不正确'); + } + + // 检查是否已存在(通过身份证哈希) + $idCardHash = EncryptionHelper::hash($idCard); + $existingUser = $this->userProfileRepository->newQuery() + ->where('id_card_hash', $idCardHash) + ->first(); + + if ($existingUser) { + throw new \InvalidArgumentException('该身份证号已存在,user_id: ' . $existingUser->user_id); + } + + // 加密身份证 + $idCardEncrypted = EncryptionHelper::encrypt($idCard); + + // 生成用户ID + $userId = $data['user_id'] ?? UuidGenerator::uuid4()->toString(); + + $now = new \DateTimeImmutable('now'); + + // 从身份证号中自动提取基础信息(如果未提供) + $idCardInfo = IdCardHelper::extractInfo($idCard); + $gender = isset($data['gender']) ? (int)$data['gender'] : ($idCardInfo['gender'] > 0 ? $idCardInfo['gender'] : null); + $birthday = isset($data['birthday']) ? new \DateTimeImmutable($data['birthday']) : $idCardInfo['birthday']; + + // 创建用户记录 + $user = new UserProfileRepository(); + $user->user_id = $userId; + $user->id_card_hash = $idCardHash; + $user->id_card_encrypted = $idCardEncrypted; + $user->id_card_type = $idCardType; + $user->name = $data['name'] ?? null; + $user->phone = $data['phone'] ?? null; + $user->address = $data['address'] ?? null; + $user->email = $data['email'] ?? null; + $user->gender = $gender; + $user->birthday = $birthday; + $user->total_amount = isset($data['total_amount']) ? (float)$data['total_amount'] : 0; + $user->total_count = isset($data['total_count']) ? (int)$data['total_count'] : 0; + $user->last_consume_time = isset($data['last_consume_time']) ? new \DateTimeImmutable($data['last_consume_time']) : null; + $user->status = isset($data['status']) ? (int)$data['status'] : 0; + $user->create_time = $now; + $user->update_time = $now; + + $user->save(); + + LoggerHelper::logBusiness('user_created', [ + 'user_id' => $userId, + 'name' => $user->name, + 'id_card_type' => $idCardType, + ]); + + return [ + 'user_id' => $userId, + 'name' => $user->name, + 'phone' => $user->phone, + 'id_card_type' => $idCardType, + 'create_time' => $user->create_time, + ]; + } + + /** + * 根据 user_id 获取用户信息 + * + * @param string $userId 用户ID + * @param bool $decryptIdCard 是否解密身份证(需要权限控制) + * @return array|null 用户信息 + */ + public function getUserById(string $userId, bool $decryptIdCard = false): ?array + { + $user = $this->userProfileRepository->findByUserId($userId); + + if (!$user) { + return null; + } + + $result = [ + 'user_id' => $user->user_id, + 'name' => $user->name, + 'phone' => $user->phone, + 'address' => $user->address, + 'email' => $user->email, + 'gender' => $user->gender, + 'birthday' => $user->birthday, + 'id_card_type' => $user->id_card_type, + 'total_amount' => $user->total_amount, + 'total_count' => $user->total_count, + 'last_consume_time' => $user->last_consume_time, + 'tags_update_time' => $user->tags_update_time, + 'status' => $user->status, + 'create_time' => $user->create_time, + 'update_time' => $user->update_time, + ]; + + // 如果需要解密身份证(需要权限控制) + if ($decryptIdCard) { + try { + $result['id_card'] = EncryptionHelper::decrypt($user->id_card_encrypted); + } catch (\Throwable $e) { + LoggerHelper::logError($e, ['user_id' => $userId, 'action' => 'decrypt_id_card']); + $result['id_card'] = null; + $result['decrypt_error'] = '解密失败'; + } + } else { + // 返回脱敏的身份证 + $result['id_card_encrypted'] = $user->id_card_encrypted; + } + + return $result; + } + + /** + * 根据身份证号查找用户(通过哈希匹配) + * + * @param string $idCard 身份证号 + * @return array|null 用户信息 + */ + public function findUserByIdCard(string $idCard): ?array + { + $idCardHash = EncryptionHelper::hash($idCard); + $user = $this->userProfileRepository->newQuery() + ->where('id_card_hash', $idCardHash) + ->first(); + + if (!$user) { + return null; + } + + return $this->getUserById($user->user_id, false); + } + + /** + * 更新用户信息 + * + * @param string $userId 用户ID + * @param array $data 要更新的用户数据 + * @return array 更新后的用户信息 + * @throws \InvalidArgumentException + */ + public function updateUser(string $userId, array $data): array + { + $user = $this->userProfileRepository->findByUserId($userId); + + if (!$user) { + throw new \InvalidArgumentException("用户不存在: {$userId}"); + } + + $now = new \DateTimeImmutable('now'); + + // 更新允许修改的字段 + if (isset($data['name'])) { + $user->name = $data['name']; + } + if (isset($data['phone'])) { + $user->phone = $data['phone']; + } + if (isset($data['email'])) { + $user->email = $data['email']; + } + if (isset($data['address'])) { + $user->address = $data['address']; + } + if (isset($data['gender'])) { + $user->gender = (int)$data['gender']; + } + if (isset($data['birthday'])) { + $user->birthday = new \DateTimeImmutable($data['birthday']); + } + if (isset($data['status'])) { + $user->status = (int)$data['status']; + } + + $user->update_time = $now; + $user->save(); + + LoggerHelper::logBusiness('user_updated', [ + 'user_id' => $userId, + 'updated_fields' => array_keys($data), + ]); + + return $this->getUserById($userId, false); + } + + /** + * 删除用户(软删除,设置状态为禁用) + * + * @param string $userId 用户ID + * @return bool 是否删除成功 + * @throws \InvalidArgumentException + */ + public function deleteUser(string $userId): bool + { + $user = $this->userProfileRepository->findByUserId($userId); + + if (!$user) { + throw new \InvalidArgumentException("用户不存在: {$userId}"); + } + + // 软删除:设置状态为禁用 + $user->status = 1; // 1 表示禁用 + $user->update_time = new \DateTimeImmutable('now'); + $user->save(); + + LoggerHelper::logBusiness('user_deleted', [ + 'user_id' => $userId, + ]); + + return true; + } + + /** + * 搜索用户(支持多种条件组合) + * + * @param array $conditions 搜索条件 + * - name: 姓名(模糊搜索) + * - phone: 手机号(精确或模糊) + * - email: 邮箱(精确或模糊) + * - id_card: 身份证号(精确匹配) + * - gender: 性别(0-未知,1-男,2-女) + * - status: 状态(0-正常,1-禁用) + * - min_total_amount: 最小总消费金额 + * - max_total_amount: 最大总消费金额 + * - min_total_count: 最小消费次数 + * - max_total_count: 最大消费次数 + * @param int $page 页码(从1开始) + * @param int $pageSize 每页数量 + * @return array 返回用户列表和分页信息 + */ + public function searchUsers(array $conditions, int $page = 1, int $pageSize = 20): array + { + $query = $this->userProfileRepository->newQuery(); + + // 姓名模糊搜索(MongoDB 使用正则表达式) + if (!empty($conditions['name'])) { + $namePattern = preg_quote($conditions['name'], '/'); + $query->where('name', 'regex', "/{$namePattern}/i"); + } + + // 手机号搜索(支持精确和模糊) + if (!empty($conditions['phone'])) { + if (isset($conditions['phone_exact']) && $conditions['phone_exact']) { + // 精确匹配 + $query->where('phone', $conditions['phone']); + } else { + // 模糊匹配(MongoDB 使用正则表达式) + $phonePattern = preg_quote($conditions['phone'], '/'); + $query->where('phone', 'regex', "/{$phonePattern}/i"); + } + } + + // 邮箱搜索(支持精确和模糊) + if (!empty($conditions['email'])) { + if (isset($conditions['email_exact']) && $conditions['email_exact']) { + // 精确匹配 + $query->where('email', $conditions['email']); + } else { + // 模糊匹配(MongoDB 使用正则表达式) + $emailPattern = preg_quote($conditions['email'], '/'); + $query->where('email', 'regex', "/{$emailPattern}/i"); + } + } + + // 如果指定了 user_ids,限制搜索范围 + if (!empty($conditions['user_ids']) && is_array($conditions['user_ids'])) { + $query->whereIn('user_id', $conditions['user_ids']); + } + + // 身份证号精确匹配(通过哈希) + if (!empty($conditions['id_card'])) { + $idCardHash = EncryptionHelper::hash($conditions['id_card']); + $query->where('id_card_hash', $idCardHash); + } + + // 性别筛选 + if (isset($conditions['gender']) && $conditions['gender'] !== '') { + $query->where('gender', (int)$conditions['gender']); + } + + // 状态筛选 + if (isset($conditions['status']) && $conditions['status'] !== '') { + $query->where('status', (int)$conditions['status']); + } + + // 总消费金额范围 + if (isset($conditions['min_total_amount'])) { + $query->where('total_amount', '>=', (float)$conditions['min_total_amount']); + } + if (isset($conditions['max_total_amount'])) { + $query->where('total_amount', '<=', (float)$conditions['max_total_amount']); + } + + // 消费次数范围 + if (isset($conditions['min_total_count'])) { + $query->where('total_count', '>=', (int)$conditions['min_total_count']); + } + if (isset($conditions['max_total_count'])) { + $query->where('total_count', '<=', (int)$conditions['max_total_count']); + } + + // 分页 + $total = $query->count(); + $users = $query->skip(($page - 1) * $pageSize) + ->take($pageSize) + ->orderBy('create_time', 'desc') + ->get(); + + // 转换为数组格式 + $result = []; + foreach ($users as $user) { + $result[] = [ + 'user_id' => $user->user_id, + 'name' => $user->name, + 'phone' => $user->phone, + 'email' => $user->email, + 'address' => $user->address, + 'gender' => $user->gender, + 'birthday' => $user->birthday, + 'id_card_type' => $user->id_card_type, + 'total_amount' => $user->total_amount, + 'total_count' => $user->total_count, + 'last_consume_time' => $user->last_consume_time, + 'tags_update_time' => $user->tags_update_time, + 'status' => $user->status, + 'create_time' => $user->create_time, + 'update_time' => $user->update_time, + ]; + } + + return [ + 'users' => $result, + 'total' => $total, + 'page' => $page, + 'page_size' => $pageSize, + 'total_pages' => (int)ceil($total / $pageSize), + ]; + } + + /** + * 验证身份证号格式(简单验证) + * + * @param string $idCard 身份证号 + * @return bool + */ + protected function validateIdCard(string $idCard): bool + { + // 15位或18位数字,最后一位可能是X + return preg_match('/^(\d{15}|\d{17}[\dXx])$/', $idCard) === 1; + } +} + diff --git a/app/utils/ApiResponseHelper.php b/app/utils/ApiResponseHelper.php new file mode 100644 index 0000000..e1433d0 --- /dev/null +++ b/app/utils/ApiResponseHelper.php @@ -0,0 +1,137 @@ + 0, + 'msg' => $message, + ]; + + if ($data !== null) { + $response['data'] = $data; + } + + return json($response, $httpCode); + } + + /** + * 错误响应 + * + * @param string $message 错误消息 + * @param int $code 错误码(业务错误码,非HTTP状态码) + * @param int $httpCode HTTP状态码 + * @param array $extra 额外信息 + * @return \support\Response + */ + public static function error( + string $message, + int $code = 400, + int $httpCode = 400, + array $extra = [] + ): \support\Response { + $response = [ + 'code' => $code, + 'msg' => $message, + ]; + + // 开发环境可以返回更多调试信息 + if (self::isDevelopment() && !empty($extra)) { + $response = array_merge($response, $extra); + } + + return json($response, $httpCode); + } + + /** + * 异常响应 + * + * @param \Throwable $exception 异常对象 + * @param int $httpCode HTTP状态码 + * @return \support\Response + */ + public static function exception(\Throwable $exception, int $httpCode = 500): \support\Response + { + // 记录错误日志 + LoggerHelper::logError($exception); + + $code = 500; + $message = '内部服务器错误'; + + // 根据异常类型设置错误码和消息 + if ($exception instanceof \InvalidArgumentException) { + $code = 400; + $message = $exception->getMessage(); + } elseif ($exception instanceof \RuntimeException) { + $code = 500; + $message = $exception->getMessage(); + } + + $response = [ + 'code' => $code, + 'msg' => $message, + ]; + + // 开发环境返回详细错误信息 + if (self::isDevelopment()) { + $response['debug'] = [ + 'message' => $exception->getMessage(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => $exception->getTraceAsString(), + ]; + } + + return json($response, $httpCode); + } + + /** + * 验证错误响应 + * + * @param array $errors 验证错误列表 + * @return \support\Response + */ + public static function validationError(array $errors): \support\Response + { + $message = '参数验证失败'; + if (!empty($errors)) { + $firstError = reset($errors); + $message = is_array($firstError) ? $firstError[0] : $firstError; + } + + $response = [ + 'code' => 400, + 'msg' => $message, + 'errors' => $errors, + ]; + + return json($response, 400); + } +} + diff --git a/app/utils/DataMaskingHelper.php b/app/utils/DataMaskingHelper.php new file mode 100644 index 0000000..7d5d640 --- /dev/null +++ b/app/utils/DataMaskingHelper.php @@ -0,0 +1,143 @@ + $data 数据数组 + * @param array $sensitiveFields 敏感字段列表(如:['id_card', 'phone', 'email']) + * @return array 脱敏后的数组 + */ + public static function maskArray(array $data, array $sensitiveFields = ['id_card', 'id_card_encrypted', 'phone', 'email']): array + { + $masked = $data; + + foreach ($sensitiveFields as $field) { + if (isset($masked[$field]) && is_string($masked[$field])) { + switch ($field) { + case 'id_card': + case 'id_card_encrypted': + $masked[$field] = self::maskIdCard($masked[$field]); + break; + case 'phone': + $masked[$field] = self::maskPhone($masked[$field]); + break; + case 'email': + $masked[$field] = self::maskEmail($masked[$field]); + break; + } + } + } + + return $masked; + } +} + diff --git a/app/utils/EncryptionHelper.php b/app/utils/EncryptionHelper.php new file mode 100644 index 0000000..40cc7d9 --- /dev/null +++ b/app/utils/EncryptionHelper.php @@ -0,0 +1,141 @@ + $currentYearLastTwo) { + $year += 1900; + } else { + $year += 2000; + } + } else { + return null; + } + + // 验证日期是否有效 + if (!checkdate($month, $day, $year)) { + return null; + } + + try { + return new \DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day)); + } catch (\Throwable $e) { + return null; + } + } + + /** + * 从身份证号中提取性别 + * + * @param string $idCard 身份证号(15位或18位) + * @return int 性别:1=男,2=女,0=未知 + */ + public static function extractGender(string $idCard): int + { + $idCard = trim($idCard); + $length = strlen($idCard); + + if ($length === 18) { + // 18位身份证:第17位(索引16)是性别码 + $genderCode = (int)substr($idCard, 16, 1); + } elseif ($length === 15) { + // 15位身份证:第15位(索引14)是性别码 + $genderCode = (int)substr($idCard, 14, 1); + } else { + return 0; // 未知 + } + + // 奇数表示男性,偶数表示女性 + return ($genderCode % 2 === 1) ? 1 : 2; + } + + /** + * 从身份证号中提取所有可解析的信息 + * + * @param string $idCard 身份证号 + * @return array 包含 birthday 和 gender 的数组 + */ + public static function extractInfo(string $idCard): array + { + return [ + 'birthday' => self::extractBirthday($idCard), + 'gender' => self::extractGender($idCard), + ]; + } +} + diff --git a/app/utils/LogMaskingProcessor.php b/app/utils/LogMaskingProcessor.php new file mode 100644 index 0000000..355125f --- /dev/null +++ b/app/utils/LogMaskingProcessor.php @@ -0,0 +1,137 @@ + + */ + protected array $sensitiveFields = [ + 'id_card', + 'id_card_encrypted', + 'id_card_hash', + 'phone', + 'email', + 'password', + 'token', + 'secret', + ]; + + /** + * 处理日志记录,对敏感信息进行脱敏 + * + * @param array $record Monolog 2.x 格式的日志记录数组 + * @return array 处理后的日志记录数组 + */ + public function __invoke(array $record): array + { + // 处理 context 中的敏感信息 + if (isset($record['context']) && is_array($record['context'])) { + $record['context'] = $this->maskArray($record['context']); + } + + // 处理 extra 中的敏感信息 + if (isset($record['extra']) && is_array($record['extra'])) { + $record['extra'] = $this->maskArray($record['extra']); + } + + // 对消息本身也进行脱敏(如果包含敏感信息) + if (isset($record['message']) && is_string($record['message'])) { + $record['message'] = $this->maskString($record['message']); + } + + return $record; + } + + /** + * 脱敏数组中的敏感字段 + * + * @param array $data + * @return array + */ + protected function maskArray(array $data): array + { + $masked = []; + foreach ($data as $key => $value) { + $lowerKey = strtolower($key); + + // 检查字段名是否包含敏感关键词 + $isSensitive = false; + foreach ($this->sensitiveFields as $field) { + if (strpos($lowerKey, $field) !== false) { + $isSensitive = true; + break; + } + } + + if ($isSensitive && is_string($value)) { + // 根据字段类型选择脱敏方法 + if (strpos($lowerKey, 'phone') !== false) { + $masked[$key] = DataMaskingHelper::maskPhone($value); + } elseif (strpos($lowerKey, 'email') !== false) { + $masked[$key] = DataMaskingHelper::maskEmail($value); + } elseif (strpos($lowerKey, 'id_card') !== false) { + $masked[$key] = DataMaskingHelper::maskIdCard($value); + } else { + // 其他敏感字段,用*替代 + $masked[$key] = str_repeat('*', min(strlen($value), 20)); + } + } elseif (is_array($value)) { + $masked[$key] = $this->maskArray($value); + } else { + $masked[$key] = $value; + } + } + return $masked; + } + + /** + * 脱敏字符串中的敏感信息(简单模式,匹配常见格式) + * + * @param string $message + * @return string + */ + protected function maskString(string $message): string + { + // 匹配身份证号(18位或15位数字) + $message = preg_replace_callback( + '/\b\d{15}(\d{3})?[Xx]?\b/', + function ($matches) { + return DataMaskingHelper::maskIdCard($matches[0]); + }, + $message + ); + + // 匹配手机号(11位数字,1开头) + $message = preg_replace_callback( + '/\b1[3-9]\d{9}\b/', + function ($matches) { + return DataMaskingHelper::maskPhone($matches[0]); + }, + $message + ); + + // 匹配邮箱 + $message = preg_replace_callback( + '/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/', + function ($matches) { + return DataMaskingHelper::maskEmail($matches[0]); + }, + $message + ); + + return $message; + } +} + diff --git a/app/utils/LoggerHelper.php b/app/utils/LoggerHelper.php new file mode 100644 index 0000000..113c82d --- /dev/null +++ b/app/utils/LoggerHelper.php @@ -0,0 +1,155 @@ + $params 请求参数 + * @param float|null $duration 请求耗时(秒) + */ + public static function logRequest(string $method, string $path, array $params = [], ?float $duration = null): void + { + $logger = \support\Log::channel('default'); + $context = [ + 'type' => 'request', + 'method' => $method, + 'path' => $path, + 'params' => $params, + ]; + + if ($duration !== null) { + $context['duration'] = round($duration * 1000, 2) . 'ms'; + } + + $logger->info("请求: {$method} {$path}", $context); + } + + /** + * 记录业务日志 + * + * @param string $action 操作名称 + * @param array $context 上下文信息 + * @param string $level 日志级别(info/warning/error) + */ + public static function logBusiness(string $action, array $context = [], string $level = 'info'): void + { + $logger = \support\Log::channel('default'); + $context['type'] = 'business'; + $context['action'] = $action; + + $logger->$level("业务操作: {$action}", $context); + } + + /** + * 记录标签计算日志 + * + * @param string $userId 用户ID + * @param string $tagId 标签ID + * @param array $result 计算结果 + * @param float|null $duration 计算耗时(秒) + */ + public static function logTagCalculation(string $userId, string $tagId, array $result, ?float $duration = null): void + { + $logger = \support\Log::channel('default'); + $context = [ + 'type' => 'tag_calculation', + 'user_id' => $userId, + 'tag_id' => $tagId, + 'result' => $result, + ]; + + if ($duration !== null) { + $context['duration'] = round($duration * 1000, 2) . 'ms'; + } + + $logger->info("标签计算: user_id={$userId}, tag_id={$tagId}", $context); + } + + /** + * 记录错误日志 + * + * @param \Throwable $exception 异常对象 + * @param array $context 额外上下文 + */ + public static function logError(\Throwable $exception, array $context = []): void + { + $logger = \support\Log::channel('default'); + $context['type'] = 'error'; + + // 限制 trace 长度,避免内存溢出 + $trace = $exception->getTraceAsString(); + $originalTraceLength = strlen($trace); + $maxTraceLength = 5000; // 最大 trace 长度(字符数) + + // 限制 trace 行数,只保留前50行 + $traceLines = explode("\n", $trace); + $originalLineCount = count($traceLines); + + if ($originalLineCount > 50) { + $traceLines = array_slice($traceLines, 0, 50); + $trace = implode("\n", $traceLines) . "\n... (trace truncated, total lines: {$originalLineCount})"; + } + + // 限制 trace 字符长度 + if (strlen($trace) > $maxTraceLength) { + $trace = substr($trace, 0, $maxTraceLength) . "\n... (trace truncated, total length: {$originalTraceLength} bytes)"; + } + + $context['exception'] = [ + 'message' => $exception->getMessage(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => $trace, + 'class' => get_class($exception), + ]; + + // 如果上下文数据太大,也进行限制 + $contextJson = json_encode($context); + if (strlen($contextJson) > 10000) { + // 如果上下文太大,只保留关键信息 + $context = [ + 'type' => 'error', + 'exception' => [ + 'message' => $exception->getMessage(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'class' => get_class($exception), + 'trace' => substr($trace, 0, 2000) . '... (truncated)', + ], + ]; + } + + $logger->error("异常: {$exception->getMessage()}", $context); + } + + /** + * 记录性能日志 + * + * @param string $operation 操作名称 + * @param float $duration 耗时(秒) + * @param array $context 上下文信息 + */ + public static function logPerformance(string $operation, float $duration, array $context = []): void + { + $logger = \support\Log::channel('default'); + $context['type'] = 'performance'; + $context['operation'] = $operation; + $context['duration'] = round($duration * 1000, 2) . 'ms'; + + $level = $duration > 1.0 ? 'warning' : 'info'; + $logger->$level("性能: {$operation} 耗时 {$context['duration']}", $context); + } +} + diff --git a/app/utils/MongoDBHelper.php b/app/utils/MongoDBHelper.php new file mode 100644 index 0000000..66f6320 --- /dev/null +++ b/app/utils/MongoDBHelper.php @@ -0,0 +1,55 @@ + $config 数据库配置 + * @return string DSN 字符串 + */ + public static function buildDsn(array $config): string + { + $host = $config['host'] ?? '192.168.1.106'; + $port = $config['port'] ?? 27017; + $username = $config['username'] ?? ''; + $password = $config['password'] ?? ''; + $authSource = $config['auth_source'] ?? 'admin'; + + if (!empty($username) && !empty($password)) { + return "mongodb://{$username}:{$password}@{$host}:{$port}/{$authSource}"; + } + + return "mongodb://{$host}:{$port}"; + } + + /** + * 创建 MongoDB 客户端 + * + * @param array $config 数据库配置 + * @param array $options 额外选项(可选) + * @return Client MongoDB 客户端实例 + */ + public static function createClient(array $config, array $options = []): Client + { + $defaultOptions = [ + 'connectTimeoutMS' => 5000, + 'socketTimeoutMS' => 5000, + ]; + + return new Client( + self::buildDsn($config), + array_merge($defaultOptions, $options) + ); + } +} + diff --git a/app/utils/QueueService.php b/app/utils/QueueService.php new file mode 100644 index 0000000..59451df --- /dev/null +++ b/app/utils/QueueService.php @@ -0,0 +1,247 @@ +isConnected()) { + return; + } + + $config = config('queue.connections.rabbitmq'); + self::$config = $config; + + try { + self::$connection = new AMQPStreamConnection( + $config['host'], + $config['port'], + $config['user'], + $config['password'], + $config['vhost'], + false, // insist + 'AMQPLAIN', // login_method + null, // login_response + 'en_US', // locale + $config['timeout'] ?? 10.0, // connection_timeout + $config['timeout'] ?? 10.0, // read_write_timeout + null, // context + false, // keepalive + $config['heartbeat'] ?? 0 // heartbeat + ); + + self::$channel = self::$connection->channel(); + + // 声明数据同步交换机 + if (isset($config['exchanges']['data_sync'])) { + $exchangeConfig = $config['exchanges']['data_sync']; + self::$channel->exchange_declare( + $exchangeConfig['name'], + $exchangeConfig['type'], + false, // passive + $exchangeConfig['durable'], + $exchangeConfig['auto_delete'] + ); + } + + // 声明标签计算交换机 + if (isset($config['exchanges']['tag_calculation'])) { + $exchangeConfig = $config['exchanges']['tag_calculation']; + self::$channel->exchange_declare( + $exchangeConfig['name'], + $exchangeConfig['type'], + false, // passive + $exchangeConfig['durable'], + $exchangeConfig['auto_delete'] + ); + } + + // 声明队列 + if (isset($config['queues']['tag_calculation'])) { + $queueConfig = $config['queues']['tag_calculation']; + self::$channel->queue_declare( + $queueConfig['name'], + false, // passive + $queueConfig['durable'], + false, // exclusive + $queueConfig['auto_delete'], + false, // nowait + $queueConfig['arguments'] ?? [] + ); + + // 绑定队列到交换机 + if (isset($config['routing_keys']['tag_calculation'])) { + self::$channel->queue_bind( + $queueConfig['name'], + $config['exchanges']['tag_calculation']['name'], + $config['routing_keys']['tag_calculation'] + ); + } + } + + LoggerHelper::logBusiness('queue_connection_established', [ + 'host' => $config['host'], + 'port' => $config['port'], + ]); + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'QueueService', + 'action' => 'initConnection', + ]); + throw $e; + } + } + + /** + * 推送消息到数据同步队列 + * + * @param array $data 消息数据(包含数据源ID、数据记录等) + * @return bool 是否推送成功 + */ + public static function pushDataSync(array $data): bool + { + try { + self::initConnection(); + + $config = self::$config; + $messageConfig = config('queue.message', []); + + $messageBody = json_encode($data, JSON_UNESCAPED_UNICODE); + $message = new AMQPMessage( + $messageBody, + [ + 'delivery_mode' => $messageConfig['delivery_mode'] ?? AMQPMessage::DELIVERY_MODE_PERSISTENT, + 'content_type' => $messageConfig['content_type'] ?? 'application/json', + ] + ); + + $exchangeName = $config['exchanges']['data_sync']['name']; + $routingKey = $config['routing_keys']['data_sync']; + + self::$channel->basic_publish($message, $exchangeName, $routingKey); + + LoggerHelper::logBusiness('queue_message_pushed', [ + 'queue' => 'data_sync', + 'data' => $data, + ]); + + return true; + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'QueueService', + 'action' => 'pushDataSync', + 'data' => $data, + ]); + return false; + } + } + + /** + * 推送消息到标签计算队列 + * + * @param array $data 消息数据 + * @return bool 是否推送成功 + */ + public static function pushTagCalculation(array $data): bool + { + try { + self::initConnection(); + + $config = self::$config; + $messageConfig = config('queue.message', []); + + $messageBody = json_encode($data, JSON_UNESCAPED_UNICODE); + $message = new AMQPMessage( + $messageBody, + [ + 'delivery_mode' => $messageConfig['delivery_mode'] ?? AMQPMessage::DELIVERY_MODE_PERSISTENT, + 'content_type' => $messageConfig['content_type'] ?? 'application/json', + ] + ); + + $exchangeName = $config['exchanges']['tag_calculation']['name']; + $routingKey = $config['routing_keys']['tag_calculation']; + + self::$channel->basic_publish($message, $exchangeName, $routingKey); + + LoggerHelper::logBusiness('queue_message_pushed', [ + 'queue' => 'tag_calculation', + 'data' => $data, + ]); + + return true; + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'QueueService', + 'action' => 'pushTagCalculation', + 'data' => $data, + ]); + return false; + } + } + + /** + * 关闭连接 + */ + public static function closeConnection(): void + { + try { + if (self::$channel !== null) { + self::$channel->close(); + self::$channel = null; + } + if (self::$connection !== null && self::$connection->isConnected()) { + self::$connection->close(); + self::$connection = null; + } + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'QueueService', + 'action' => 'closeConnection', + ]); + } + } + + /** + * 获取通道(用于消费者) + * + * @return AMQPChannel + */ + public static function getChannel(): AMQPChannel + { + self::initConnection(); + return self::$channel; + } + + /** + * 获取连接(用于消费者) + * + * @return AMQPStreamConnection + */ + public static function getConnection(): AMQPStreamConnection + { + self::initConnection(); + return self::$connection; + } +} + diff --git a/app/utils/RedisHelper.php b/app/utils/RedisHelper.php new file mode 100644 index 0000000..78b621d --- /dev/null +++ b/app/utils/RedisHelper.php @@ -0,0 +1,267 @@ + $sessionConfig['host'] ?? getenv('REDIS_HOST') ?: '127.0.0.1', + 'port' => (int)($sessionConfig['port'] ?? getenv('REDIS_PORT') ?: 6379), + 'password' => $sessionConfig['auth'] ?? getenv('REDIS_PASSWORD') ?: null, + 'database' => (int)($sessionConfig['database'] ?? getenv('REDIS_DATABASE') ?: 0), + 'timeout' => $sessionConfig['timeout'] ?? 2.0, + ]; + + $parameters = [ + 'host' => self::$config['host'], + 'port' => self::$config['port'], + ]; + + if (!empty(self::$config['password'])) { + $parameters['password'] = self::$config['password']; + } + + if (self::$config['database'] > 0) { + $parameters['database'] = self::$config['database']; + } + + $options = [ + 'timeout' => self::$config['timeout'], + ]; + + self::$client = new Client($parameters, $options); + + // 测试连接 + try { + self::$client->ping(); + LoggerHelper::logBusiness('redis_connected', [ + 'host' => self::$config['host'], + 'port' => self::$config['port'], + ]); + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'RedisHelper', + 'action' => 'getClient', + ]); + throw $e; + } + + return self::$client; + } + + /** + * 获取分布式锁 + * + * @param string $key 锁的键 + * @param int $ttl 锁的过期时间(秒) + * @param int $retryTimes 重试次数 + * @param int $retryDelay 重试延迟(毫秒) + * @return bool 是否获取成功 + */ + public static function acquireLock(string $key, int $ttl = 300, int $retryTimes = 3, int $retryDelay = 1000): bool + { + $client = self::getClient(); + $lockKey = "lock:{$key}"; + $lockValue = uniqid(gethostname() . '_', true); // 唯一值,用于安全释放锁 + + for ($i = 0; $i <= $retryTimes; $i++) { + // 尝试获取锁(SET key value NX EX ttl) + $result = $client->set($lockKey, $lockValue, 'EX', $ttl, 'NX'); + + if ($result) { + LoggerHelper::logBusiness('redis_lock_acquired', [ + 'key' => $key, + 'ttl' => $ttl, + ]); + return true; + } + + // 如果还有重试机会,等待后重试 + if ($i < $retryTimes) { + usleep($retryDelay * 1000); // 转换为微秒 + } + } + + LoggerHelper::logBusiness('redis_lock_failed', [ + 'key' => $key, + 'retry_times' => $retryTimes, + ]); + + return false; + } + + /** + * 释放分布式锁 + * + * @param string $key 锁的键 + * @return bool 是否释放成功 + */ + public static function releaseLock(string $key): bool + { + $client = self::getClient(); + $lockKey = "lock:{$key}"; + + try { + $result = $client->del([$lockKey]); + + if ($result > 0) { + LoggerHelper::logBusiness('redis_lock_released', [ + 'key' => $key, + ]); + return true; + } + + return false; + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'RedisHelper', + 'action' => 'releaseLock', + 'key' => $key, + ]); + return false; + } + } + + /** + * 设置键值对 + * + * @param string $key 键 + * @param mixed $value 值 + * @param int|null $ttl 过期时间(秒),null 表示不过期 + * @return bool 是否设置成功 + */ + public static function set(string $key, $value, ?int $ttl = null): bool + { + try { + $client = self::getClient(); + $serialized = is_string($value) ? $value : json_encode($value, JSON_UNESCAPED_UNICODE); + + if ($ttl !== null) { + $client->setex($key, $ttl, $serialized); + } else { + $client->set($key, $serialized); + } + + return true; + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'RedisHelper', + 'action' => 'set', + 'key' => $key, + ]); + return false; + } + } + + /** + * 获取键值 + * + * @param string $key 键 + * @return mixed 值,不存在返回 null + */ + public static function get(string $key) + { + try { + $client = self::getClient(); + $value = $client->get($key); + + if ($value === null) { + return null; + } + + // 尝试 JSON 解码 + $decoded = json_decode($value, true); + return $decoded !== null ? $decoded : $value; + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'RedisHelper', + 'action' => 'get', + 'key' => $key, + ]); + return null; + } + } + + /** + * 删除键 + * + * @param string $key 键 + * @return bool 是否删除成功 + */ + public static function delete(string $key): bool + { + try { + $client = self::getClient(); + $result = $client->del([$key]); + return $result > 0; + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'RedisHelper', + 'action' => 'delete', + 'key' => $key, + ]); + return false; + } + } + + /** + * 检查键是否存在 + * + * @param string $key 键 + * @return bool 是否存在 + */ + public static function exists(string $key): bool + { + try { + $client = self::getClient(); + $result = $client->exists($key); + return $result > 0; + } catch (\Throwable $e) { + LoggerHelper::logError($e, [ + 'component' => 'RedisHelper', + 'action' => 'exists', + 'key' => $key, + ]); + return false; + } + } + + /** + * 删除键(别名,兼容del方法) + * + * @param string $key 键 + * @return bool 是否删除成功 + */ + public static function del(string $key): bool + { + return self::delete($key); + } +} + diff --git a/config/data_collection_tasks.php b/config/data_collection_tasks.php new file mode 100644 index 0000000..c079822 --- /dev/null +++ b/config/data_collection_tasks.php @@ -0,0 +1,74 @@ + [ + // 分布式锁配置 + 'distributed_lock' => [ + 'driver' => 'redis', + 'ttl' => 300, + 'retry_times' => 3, + 'retry_delay' => 1000, + ], + // 错误处理配置 + 'error_handling' => [ + 'max_retries' => 3, + 'retry_delay' => 5, + 'circuit_breaker' => [ + 'enabled' => true, + 'failure_threshold' => 10, + 'recovery_timeout' => 60, + ], + ], + ], + + // 采集任务列表(配置文件中的任务) + 'tasks' => [ + // 数据库同步任务(实时同步源数据库到目标数据库) + // 注意:这是一个系统级任务,通过配置文件管理,不从数据库加载 + 'database_sync' => [ + 'name' => '数据库实时同步', + 'enabled' => false, + + // 源数据库(从 data_sources.php 引用) + 'source_data_source' => 'kr_mongodb', + + // 目标数据库(从 data_sources.php 引用) + 'target_data_source' => 'sync_mongodb', + + // 业务处理类 + 'handler_class' => \app\service\DataCollection\Handler\DatabaseSyncHandler::class, + + // 调度配置(数据库同步是持续运行的,不需要定时调度) + 'schedule' => [ + 'cron' => null, // 不需要 Cron,启动后持续运行 + 'enabled' => false, // 禁用定时调度,启动后持续运行 + ], + + // 分片配置(每个 Worker 可以监听不同的数据库) + 'sharding' => [ + 'strategy' => 'by_database', // 按数据库分片 + 'shard_count' => 1, + ], + + // 同步状态存储配置 + 'sync_state' => [ + 'storage' => 'file', // 使用文件存储进度(DatabaseSyncService 使用文件) + 'key_prefix' => 'database_sync:', + ], + + // 注意:业务逻辑相关配置(数据库列表、排除规则、性能配置等) + // 已移到 DatabaseSyncHandler 类中,使用默认值或从独立配置读取 + ], + ], +]; + diff --git a/config/data_sources.php b/config/data_sources.php new file mode 100644 index 0000000..83b9ac7 --- /dev/null +++ b/config/data_sources.php @@ -0,0 +1,63 @@ + [ + 'type' => 'mongodb', + 'host' => getenv('KR_MONGODB_HOST'), + 'port' => (int)getenv('KR_MONGODB_PORT'), + 'database' => getenv('KR_MONGODB_DATABASE'), + 'username' => getenv('KR_MONGODB_USER'), + 'password' => getenv('KR_MONGODB_PASSWORD'), + 'auth_source' => getenv('KR_MONGODB_AUTH_SOURCE'), + 'options' => [ + 'ssl' => false, + 'connectTimeoutMS' => 3000, + 'socketTimeoutMS' => 5000, + 'authMechanism' => 'SCRAM-SHA-256', + ], + ], + + // 标签数据库(主机标签数据库) + 'tag_mongodb' => [ + 'type' => 'mongodb', + 'host' => getenv('TAG_MONGODB_HOST') ?: '192.168.1.106', + 'port' => (int)(getenv('TAG_MONGODB_PORT') ?: 27017), + 'database' => getenv('TAG_MONGODB_DATABASE') ?: 'ckb', + 'username' => getenv('TAG_MONGODB_USER') ?: 'ckb', + 'password' => getenv('TAG_MONGODB_PASSWORD') ?: '123456', + 'auth_source' => getenv('TAG_MONGODB_AUTH') ?: 'ckb', + 'options' => [ + 'ssl' => false, + 'connectTimeoutMS' => 3000, + 'socketTimeoutMS' => 5000, + 'authMechanism' => 'SCRAM-SHA-256', + ], + ], + + // 同步目标数据库(主机同步KR数据库) + 'sync_mongodb' => [ + 'type' => 'mongodb', + 'host' => getenv('SYNC_MONGODB_HOST'), + 'port' => (int)getenv('SYNC_MONGODB_PORT'), + 'database' => getenv('SYNC_MONGODB_DATABASE') ?: 'KR', + 'username' => getenv('SYNC_MONGODB_USER'), + 'password' => getenv('SYNC_MONGODB_PASS'), + 'auth_source' => getenv('SYNC_MONGODB_AUTH'), + 'options' => [ + 'ssl' => false, + 'connectTimeoutMS' => 3000, + 'socketTimeoutMS' => 5000, + 'authMechanism' => 'SCRAM-SHA-256', + ], + ], + + +]; diff --git a/config/encryption.php b/config/encryption.php new file mode 100644 index 0000000..cac9933 --- /dev/null +++ b/config/encryption.php @@ -0,0 +1,60 @@ + [ + // 加密密钥(32字节,256位) + // 注意:生产环境应使用环境变量或密钥管理服务,不要硬编码 + // 使用 getenv() 获取环境变量,如果不存在则使用默认值 + // 默认密钥:至少32字符(实际使用时会被 SHA256 哈希处理) + 'key' => getenv('ENCRYPTION_AES_KEY') ?: 'your-32-byte-secret-key-here-12345678', + + // 加密方法 + 'cipher' => 'AES-256-CBC', + + // IV 长度(字节) + 'iv_length' => 16, + ], + + // 哈希配置 + 'hash' => [ + // 哈希算法(用于身份证哈希) + 'algorithm' => 'sha256', + + // 是否使用盐值(可选,增强安全性) + 'use_salt' => true, + + // 盐值(如果启用) + // 使用 getenv() 获取环境变量,如果不存在则使用默认值 + 'salt' => getenv('ENCRYPTION_HASH_SALT') ?: 'your-hash-salt-here', + ], + + // 脱敏配置 + 'masking' => [ + // 身份证脱敏规则:保留前6位和后4位,中间用*替代 + 'id_card' => [ + 'prefix_length' => 6, + 'suffix_length' => 4, + 'mask_char' => '*', + ], + + // 手机号脱敏规则:保留前3位和后4位,中间用*替代 + 'phone' => [ + 'prefix_length' => 3, + 'suffix_length' => 4, + 'mask_char' => '*', + ], + + // 邮箱脱敏规则:保留@前的前2位和@后的域名 + 'email' => [ + 'prefix_length' => 2, + 'mask_char' => '*', + ], + ], +]; + diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 0000000..82ac9f4 --- /dev/null +++ b/config/queue.php @@ -0,0 +1,81 @@ + 'rabbitmq', + + 'connections' => [ + 'rabbitmq' => [ + 'driver' => 'rabbitmq', + 'host' => getenv('RABBITMQ_HOST') ?: '127.0.0.1', + 'port' => (int)(getenv('RABBITMQ_PORT') ?: 5672), + 'user' => getenv('RABBITMQ_USER') ?: 'guest', + 'password' => getenv('RABBITMQ_PASSWORD') ?: 'guest', + 'vhost' => getenv('RABBITMQ_VHOST') ?: '/', + 'timeout' => 10, // 连接超时时间(秒) + + // 队列配置 + 'queues' => [ + // 数据同步队列:外部数据源轮询后推送的数据 + 'data_sync' => [ + 'name' => 'data_sync_queue', + 'durable' => true, // 队列持久化 + 'auto_delete' => false, + 'arguments' => [], + ], + // 标签计算队列:消费记录写入后触发标签计算 + 'tag_calculation' => [ + 'name' => 'tag_calculation_queue', + 'durable' => true, // 队列持久化 + 'auto_delete' => false, + 'arguments' => [], + ], + ], + + // 交换机配置 + 'exchanges' => [ + 'data_sync' => [ + 'name' => 'data_sync_exchange', + 'type' => 'direct', + 'durable' => true, + 'auto_delete' => false, + ], + 'tag_calculation' => [ + 'name' => 'tag_calculation_exchange', + 'type' => 'direct', + 'durable' => true, + 'auto_delete' => false, + ], + ], + + // 路由键配置 + 'routing_keys' => [ + 'data_sync' => 'data.sync', + 'tag_calculation' => 'tag.calculation', + ], + ], + ], + + // 消息配置 + 'message' => [ + 'delivery_mode' => 2, // 消息持久化(2 = 持久化) + 'content_type' => 'application/json', + ], + + // 消费者配置 + 'consumer' => [ + 'data_sync' => [ + 'prefetch_count' => 10, // 每次处理10条消息(批量处理) + 'no_ack' => false, // 需要确认消息 + ], + 'tag_calculation' => [ + 'prefetch_count' => 1, // 每次只处理一条消息 + 'no_ack' => false, // 需要确认消息 + ], + ], +]; + diff --git a/public/database-sync-dashboard.html b/public/database-sync-dashboard.html new file mode 100644 index 0000000..4092098 --- /dev/null +++ b/public/database-sync-dashboard.html @@ -0,0 +1,1024 @@ + + + + + + 数据库同步进度看板 + + + +
+
+

📊 数据库同步进度看板

+
+ 加载中... + 最后更新: -- +
+
+ +
+
+
+
正在加载数据...
+
+
+ + +
+ + + + + diff --git a/support/bootstrap/MongoDB.php b/support/bootstrap/MongoDB.php new file mode 100644 index 0000000..f60c130 --- /dev/null +++ b/support/bootstrap/MongoDB.php @@ -0,0 +1,73 @@ +getDatabaseManager()->extend('mongodb', function ($config, $name) { + $config['name'] = $name; + return new Connection($config); + }); + + // 添加 MongoDB 连接 + $capsule->addConnection([ + 'driver' => 'mongodb', + 'dsn' => $dsn, + 'database' => $mongoConfig['database'], + 'options' => $options, + ], 'mongodb'); + + // 设置为全局连接管理器 + $capsule->setAsGlobal(); + + // 启动 Eloquent ORM + $capsule->bootEloquent(); + } + } +} +