From ba0ebcf273a2607d23c621f79bdedcba3b5459db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B9=98=E9=A3=8E?= Date: Mon, 5 Jan 2026 10:16:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E4=B8=AD=E5=BF=83=E5=90=8C?= =?UTF-8?q?=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Moncter/MCP/MCP服务器使用说明.md | 233 ++ Moncter/MCP/README.md | 70 + Moncter/MCP/mcp.json.example | 27 + Moncter/MCP/moncter-mcp-server/.gitignore | 7 + .../MCP/moncter-mcp-server/MCP接口对比分析.md | 194 ++ .../MCP服务器同步更新说明.md | 342 ++ .../moncter-mcp-server/MCP服务器更新说明.md | 246 ++ Moncter/MCP/moncter-mcp-server/README.md | 122 + Moncter/MCP/moncter-mcp-server/install.bat | 39 + Moncter/MCP/moncter-mcp-server/install.sh | 38 + .../MCP/moncter-mcp-server/package-lock.json | 1103 +++++++ Moncter/MCP/moncter-mcp-server/package.json | 26 + Moncter/MCP/moncter-mcp-server/src/index.ts | 595 ++++ Moncter/MCP/moncter-mcp-server/tsconfig.json | 20 + Moncter/MCP/实现总结.md | 164 + Moncter/MCP/快速开始.md | 212 ++ Moncter/TaskShow/.editorconfig | 13 + Moncter/TaskShow/.gitignore | 29 + Moncter/TaskShow/README.md | 127 + Moncter/TaskShow/USAGE.md | 154 + Moncter/TaskShow/index.html | 14 + Moncter/TaskShow/package-lock.json | 2846 +++++++++++++++++ Moncter/TaskShow/package.json | 27 + Moncter/TaskShow/pnpm-lock.yaml | 1794 +++++++++++ Moncter/TaskShow/tsconfig.json | 32 + Moncter/TaskShow/tsconfig.node.json | 12 + Moncter/TaskShow/vite.config.ts | 25 + Moncter/app/command/BatchUpdateTags.php | 163 + Moncter/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 ++ Moncter/app/controller/TagController.php | 521 +++ .../controller/TagDefinitionController.php | 155 + Moncter/app/controller/TagTaskController.php | 227 ++ Moncter/app/process/DataSyncScheduler.php | 695 ++++ Moncter/app/process/DataSyncWorker.php | 212 ++ Moncter/app/process/TagCalculationWorker.php | 294 ++ .../ConsumptionRecordRepository.php | 90 + .../DataCollectionTaskRepository.php | 112 + .../app/repository/DataSourceRepository.php | 99 + Moncter/app/repository/StoreRepository.php | 123 + .../app/repository/TagCohortRepository.php | 57 + .../repository/TagDefinitionRepository.php | 64 + .../app/repository/TagHistoryRepository.php | 52 + .../repository/TagTaskExecutionRepository.php | 86 + Moncter/app/repository/TagTaskRepository.php | 95 + .../UserPhoneRelationRepository.php | 168 + .../app/repository/UserProfileRepository.php | 227 ++ Moncter/app/repository/UserTagRepository.php | 62 + Moncter/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 ++ Moncter/app/service/DataSourceService.php | 498 +++ Moncter/app/service/DataSyncService.php | 242 ++ Moncter/app/service/DatabaseSyncService.php | 1417 ++++++++ Moncter/app/service/IdentifierService.php | 312 ++ Moncter/app/service/PersonMergeService.php | 497 +++ Moncter/app/service/StoreService.php | 164 + Moncter/app/service/TagInitService.php | 226 ++ .../TagRuleEngine/SimpleRuleEngine.php | 96 + Moncter/app/service/TagService.php | 587 ++++ Moncter/app/service/TagTaskExecutor.php | 331 ++ Moncter/app/service/TagTaskService.php | 283 ++ Moncter/app/service/UserPhoneService.php | 537 ++++ Moncter/app/service/UserService.php | 395 +++ Moncter/app/utils/ApiResponseHelper.php | 137 + Moncter/app/utils/DataMaskingHelper.php | 143 + Moncter/app/utils/EncryptionHelper.php | 141 + Moncter/app/utils/IdCardHelper.php | 102 + Moncter/app/utils/LogMaskingProcessor.php | 137 + Moncter/app/utils/LoggerHelper.php | 155 + Moncter/app/utils/MongoDBHelper.php | 55 + Moncter/app/utils/QueueService.php | 247 ++ Moncter/app/utils/RedisHelper.php | 267 ++ Moncter/config/data_collection_tasks.php | 74 + Moncter/config/data_sources.php | 63 + Moncter/config/encryption.php | 60 + Moncter/config/queue.php | 81 + Moncter/public/database-sync-dashboard.html | 1024 ++++++ Moncter/support/bootstrap/MongoDB.php | 73 + 存客宝02.code-workspace | 7 + 98 files changed, 28583 insertions(+) create mode 100644 Moncter/MCP/MCP服务器使用说明.md create mode 100644 Moncter/MCP/README.md create mode 100644 Moncter/MCP/mcp.json.example create mode 100644 Moncter/MCP/moncter-mcp-server/.gitignore create mode 100644 Moncter/MCP/moncter-mcp-server/MCP接口对比分析.md create mode 100644 Moncter/MCP/moncter-mcp-server/MCP服务器同步更新说明.md create mode 100644 Moncter/MCP/moncter-mcp-server/MCP服务器更新说明.md create mode 100644 Moncter/MCP/moncter-mcp-server/README.md create mode 100644 Moncter/MCP/moncter-mcp-server/install.bat create mode 100644 Moncter/MCP/moncter-mcp-server/install.sh create mode 100644 Moncter/MCP/moncter-mcp-server/package-lock.json create mode 100644 Moncter/MCP/moncter-mcp-server/package.json create mode 100644 Moncter/MCP/moncter-mcp-server/src/index.ts create mode 100644 Moncter/MCP/moncter-mcp-server/tsconfig.json create mode 100644 Moncter/MCP/实现总结.md create mode 100644 Moncter/MCP/快速开始.md create mode 100644 Moncter/TaskShow/.editorconfig create mode 100644 Moncter/TaskShow/.gitignore create mode 100644 Moncter/TaskShow/README.md create mode 100644 Moncter/TaskShow/USAGE.md create mode 100644 Moncter/TaskShow/index.html create mode 100644 Moncter/TaskShow/package-lock.json create mode 100644 Moncter/TaskShow/package.json create mode 100644 Moncter/TaskShow/pnpm-lock.yaml create mode 100644 Moncter/TaskShow/tsconfig.json create mode 100644 Moncter/TaskShow/tsconfig.node.json create mode 100644 Moncter/TaskShow/vite.config.ts create mode 100644 Moncter/app/command/BatchUpdateTags.php create mode 100644 Moncter/app/command/InitTags.php create mode 100644 Moncter/app/controller/ConsumptionController.php create mode 100644 Moncter/app/controller/DataCollectionTaskController.php create mode 100644 Moncter/app/controller/DataSourceController.php create mode 100644 Moncter/app/controller/DatabaseSyncController.php create mode 100644 Moncter/app/controller/PersonMergeController.php create mode 100644 Moncter/app/controller/TagCohortController.php create mode 100644 Moncter/app/controller/TagController.php create mode 100644 Moncter/app/controller/TagDefinitionController.php create mode 100644 Moncter/app/controller/TagTaskController.php create mode 100644 Moncter/app/process/DataSyncScheduler.php create mode 100644 Moncter/app/process/DataSyncWorker.php create mode 100644 Moncter/app/process/TagCalculationWorker.php create mode 100644 Moncter/app/repository/ConsumptionRecordRepository.php create mode 100644 Moncter/app/repository/DataCollectionTaskRepository.php create mode 100644 Moncter/app/repository/DataSourceRepository.php create mode 100644 Moncter/app/repository/StoreRepository.php create mode 100644 Moncter/app/repository/TagCohortRepository.php create mode 100644 Moncter/app/repository/TagDefinitionRepository.php create mode 100644 Moncter/app/repository/TagHistoryRepository.php create mode 100644 Moncter/app/repository/TagTaskExecutionRepository.php create mode 100644 Moncter/app/repository/TagTaskRepository.php create mode 100644 Moncter/app/repository/UserPhoneRelationRepository.php create mode 100644 Moncter/app/repository/UserProfileRepository.php create mode 100644 Moncter/app/repository/UserTagRepository.php create mode 100644 Moncter/app/service/ConsumptionService.php create mode 100644 Moncter/app/service/DataCollection/Handler/BaseCollectionHandler.php create mode 100644 Moncter/app/service/DataCollection/Handler/ConsumptionCollectionHandler.php create mode 100644 Moncter/app/service/DataCollection/Handler/DatabaseSyncHandler.php create mode 100644 Moncter/app/service/DataCollection/Handler/GenericCollectionHandler.php create mode 100644 Moncter/app/service/DataCollection/Handler/TagTaskHandler.php create mode 100644 Moncter/app/service/DataCollection/Handler/Trait/DataCollectionHelperTrait.php create mode 100644 Moncter/app/service/DataCollectionTaskService.php create mode 100644 Moncter/app/service/DataSource/Adapter/MongoDBAdapter.php create mode 100644 Moncter/app/service/DataSource/Adapter/MySQLAdapter.php create mode 100644 Moncter/app/service/DataSource/DataSourceAdapterFactory.php create mode 100644 Moncter/app/service/DataSource/DataSourceAdapterInterface.php create mode 100644 Moncter/app/service/DataSource/PollingStrategyFactory.php create mode 100644 Moncter/app/service/DataSource/PollingStrategyInterface.php create mode 100644 Moncter/app/service/DataSource/Strategy/DefaultConsumptionStrategy.php create mode 100644 Moncter/app/service/DataSource/Strategy/MongoDBConsumptionStrategy.php create mode 100644 Moncter/app/service/DataSourceService.php create mode 100644 Moncter/app/service/DataSyncService.php create mode 100644 Moncter/app/service/DatabaseSyncService.php create mode 100644 Moncter/app/service/IdentifierService.php create mode 100644 Moncter/app/service/PersonMergeService.php create mode 100644 Moncter/app/service/StoreService.php create mode 100644 Moncter/app/service/TagInitService.php create mode 100644 Moncter/app/service/TagRuleEngine/SimpleRuleEngine.php create mode 100644 Moncter/app/service/TagService.php create mode 100644 Moncter/app/service/TagTaskExecutor.php create mode 100644 Moncter/app/service/TagTaskService.php create mode 100644 Moncter/app/service/UserPhoneService.php create mode 100644 Moncter/app/service/UserService.php create mode 100644 Moncter/app/utils/ApiResponseHelper.php create mode 100644 Moncter/app/utils/DataMaskingHelper.php create mode 100644 Moncter/app/utils/EncryptionHelper.php create mode 100644 Moncter/app/utils/IdCardHelper.php create mode 100644 Moncter/app/utils/LogMaskingProcessor.php create mode 100644 Moncter/app/utils/LoggerHelper.php create mode 100644 Moncter/app/utils/MongoDBHelper.php create mode 100644 Moncter/app/utils/QueueService.php create mode 100644 Moncter/app/utils/RedisHelper.php create mode 100644 Moncter/config/data_collection_tasks.php create mode 100644 Moncter/config/data_sources.php create mode 100644 Moncter/config/encryption.php create mode 100644 Moncter/config/queue.php create mode 100644 Moncter/public/database-sync-dashboard.html create mode 100644 Moncter/support/bootstrap/MongoDB.php create mode 100644 存客宝02.code-workspace diff --git a/Moncter/MCP/MCP服务器使用说明.md b/Moncter/MCP/MCP服务器使用说明.md new file mode 100644 index 00000000..1dd2aa4d --- /dev/null +++ b/Moncter/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/Moncter/MCP/README.md b/Moncter/MCP/README.md new file mode 100644 index 00000000..072190bc --- /dev/null +++ b/Moncter/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/Moncter/MCP/mcp.json.example b/Moncter/MCP/mcp.json.example new file mode 100644 index 00000000..ac79f5af --- /dev/null +++ b/Moncter/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/Moncter/MCP/moncter-mcp-server/.gitignore b/Moncter/MCP/moncter-mcp-server/.gitignore new file mode 100644 index 00000000..b365c4e3 --- /dev/null +++ b/Moncter/MCP/moncter-mcp-server/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +*.log +.DS_Store +.env +*.tsbuildinfo + diff --git a/Moncter/MCP/moncter-mcp-server/MCP接口对比分析.md b/Moncter/MCP/moncter-mcp-server/MCP接口对比分析.md new file mode 100644 index 00000000..2485213a --- /dev/null +++ b/Moncter/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/Moncter/MCP/moncter-mcp-server/MCP服务器同步更新说明.md b/Moncter/MCP/moncter-mcp-server/MCP服务器同步更新说明.md new file mode 100644 index 00000000..d144db11 --- /dev/null +++ b/Moncter/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/Moncter/MCP/moncter-mcp-server/MCP服务器更新说明.md b/Moncter/MCP/moncter-mcp-server/MCP服务器更新说明.md new file mode 100644 index 00000000..8c636155 --- /dev/null +++ b/Moncter/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/Moncter/MCP/moncter-mcp-server/README.md b/Moncter/MCP/moncter-mcp-server/README.md new file mode 100644 index 00000000..7e394f00 --- /dev/null +++ b/Moncter/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/Moncter/MCP/moncter-mcp-server/install.bat b/Moncter/MCP/moncter-mcp-server/install.bat new file mode 100644 index 00000000..5a077917 --- /dev/null +++ b/Moncter/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/Moncter/MCP/moncter-mcp-server/install.sh b/Moncter/MCP/moncter-mcp-server/install.sh new file mode 100644 index 00000000..17f9daba --- /dev/null +++ b/Moncter/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/Moncter/MCP/moncter-mcp-server/package-lock.json b/Moncter/MCP/moncter-mcp-server/package-lock.json new file mode 100644 index 00000000..3697e071 --- /dev/null +++ b/Moncter/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/Moncter/MCP/moncter-mcp-server/package.json b/Moncter/MCP/moncter-mcp-server/package.json new file mode 100644 index 00000000..6a6c17fc --- /dev/null +++ b/Moncter/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/Moncter/MCP/moncter-mcp-server/src/index.ts b/Moncter/MCP/moncter-mcp-server/src/index.ts new file mode 100644 index 00000000..473de05a --- /dev/null +++ b/Moncter/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/Moncter/MCP/moncter-mcp-server/tsconfig.json b/Moncter/MCP/moncter-mcp-server/tsconfig.json new file mode 100644 index 00000000..65824748 --- /dev/null +++ b/Moncter/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/Moncter/MCP/实现总结.md b/Moncter/MCP/实现总结.md new file mode 100644 index 00000000..6231f78c --- /dev/null +++ b/Moncter/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/Moncter/MCP/快速开始.md b/Moncter/MCP/快速开始.md new file mode 100644 index 00000000..cae31b21 --- /dev/null +++ b/Moncter/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/Moncter/TaskShow/.editorconfig b/Moncter/TaskShow/.editorconfig new file mode 100644 index 00000000..36894d9d --- /dev/null +++ b/Moncter/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/Moncter/TaskShow/.gitignore b/Moncter/TaskShow/.gitignore new file mode 100644 index 00000000..3309ef18 --- /dev/null +++ b/Moncter/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/Moncter/TaskShow/README.md b/Moncter/TaskShow/README.md new file mode 100644 index 00000000..2dac4321 --- /dev/null +++ b/Moncter/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/Moncter/TaskShow/USAGE.md b/Moncter/TaskShow/USAGE.md new file mode 100644 index 00000000..9347a004 --- /dev/null +++ b/Moncter/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/Moncter/TaskShow/index.html b/Moncter/TaskShow/index.html new file mode 100644 index 00000000..c9935d09 --- /dev/null +++ b/Moncter/TaskShow/index.html @@ -0,0 +1,14 @@ + + + + + + + Task Show + + +
+ + + + diff --git a/Moncter/TaskShow/package-lock.json b/Moncter/TaskShow/package-lock.json new file mode 100644 index 00000000..0359da98 --- /dev/null +++ b/Moncter/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/Moncter/TaskShow/package.json b/Moncter/TaskShow/package.json new file mode 100644 index 00000000..ea3b3427 --- /dev/null +++ b/Moncter/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/Moncter/TaskShow/pnpm-lock.yaml b/Moncter/TaskShow/pnpm-lock.yaml new file mode 100644 index 00000000..3f403d64 --- /dev/null +++ b/Moncter/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/Moncter/TaskShow/tsconfig.json b/Moncter/TaskShow/tsconfig.json new file mode 100644 index 00000000..0e2bfc7a --- /dev/null +++ b/Moncter/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/Moncter/TaskShow/tsconfig.node.json b/Moncter/TaskShow/tsconfig.node.json new file mode 100644 index 00000000..41cdb7d5 --- /dev/null +++ b/Moncter/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/Moncter/TaskShow/vite.config.ts b/Moncter/TaskShow/vite.config.ts new file mode 100644 index 00000000..0668a7d6 --- /dev/null +++ b/Moncter/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/Moncter/app/command/BatchUpdateTags.php b/Moncter/app/command/BatchUpdateTags.php new file mode 100644 index 00000000..24703a5c --- /dev/null +++ b/Moncter/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/Moncter/app/command/InitTags.php b/Moncter/app/command/InitTags.php new file mode 100644 index 00000000..973c2093 --- /dev/null +++ b/Moncter/app/command/InitTags.php @@ -0,0 +1,28 @@ +initBasicTags(); + + echo "标签初始化完成!\n"; + } +} + diff --git a/Moncter/app/controller/ConsumptionController.php b/Moncter/app/controller/ConsumptionController.php new file mode 100644 index 00000000..ba882e49 --- /dev/null +++ b/Moncter/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/Moncter/app/controller/DataCollectionTaskController.php b/Moncter/app/controller/DataCollectionTaskController.php new file mode 100644 index 00000000..6a6beb55 --- /dev/null +++ b/Moncter/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/Moncter/app/controller/DataSourceController.php b/Moncter/app/controller/DataSourceController.php new file mode 100644 index 00000000..3f1ef36e --- /dev/null +++ b/Moncter/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/Moncter/app/controller/DatabaseSyncController.php b/Moncter/app/controller/DatabaseSyncController.php new file mode 100644 index 00000000..c1ae814b --- /dev/null +++ b/Moncter/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/Moncter/app/controller/PersonMergeController.php b/Moncter/app/controller/PersonMergeController.php new file mode 100644 index 00000000..6173e0cd --- /dev/null +++ b/Moncter/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/Moncter/app/controller/TagCohortController.php b/Moncter/app/controller/TagCohortController.php new file mode 100644 index 00000000..217c0076 --- /dev/null +++ b/Moncter/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/Moncter/app/controller/TagController.php b/Moncter/app/controller/TagController.php new file mode 100644 index 00000000..630fafc8 --- /dev/null +++ b/Moncter/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/Moncter/app/controller/TagDefinitionController.php b/Moncter/app/controller/TagDefinitionController.php new file mode 100644 index 00000000..def47f88 --- /dev/null +++ b/Moncter/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/Moncter/app/controller/TagTaskController.php b/Moncter/app/controller/TagTaskController.php new file mode 100644 index 00000000..b8bb81c1 --- /dev/null +++ b/Moncter/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/Moncter/app/process/DataSyncScheduler.php b/Moncter/app/process/DataSyncScheduler.php new file mode 100644 index 00000000..5fe716e6 --- /dev/null +++ b/Moncter/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/Moncter/app/process/DataSyncWorker.php b/Moncter/app/process/DataSyncWorker.php new file mode 100644 index 00000000..1a5276e3 --- /dev/null +++ b/Moncter/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/Moncter/app/process/TagCalculationWorker.php b/Moncter/app/process/TagCalculationWorker.php new file mode 100644 index 00000000..3153e2cd --- /dev/null +++ b/Moncter/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/Moncter/app/repository/ConsumptionRecordRepository.php b/Moncter/app/repository/ConsumptionRecordRepository.php new file mode 100644 index 00000000..9b705f7f --- /dev/null +++ b/Moncter/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/Moncter/app/repository/DataCollectionTaskRepository.php b/Moncter/app/repository/DataCollectionTaskRepository.php new file mode 100644 index 00000000..b2051ac3 --- /dev/null +++ b/Moncter/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/Moncter/app/repository/DataSourceRepository.php b/Moncter/app/repository/DataSourceRepository.php new file mode 100644 index 00000000..05f82f68 --- /dev/null +++ b/Moncter/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/Moncter/app/repository/StoreRepository.php b/Moncter/app/repository/StoreRepository.php new file mode 100644 index 00000000..44b46a97 --- /dev/null +++ b/Moncter/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/Moncter/app/repository/TagCohortRepository.php b/Moncter/app/repository/TagCohortRepository.php new file mode 100644 index 00000000..4bc45799 --- /dev/null +++ b/Moncter/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/Moncter/app/repository/TagDefinitionRepository.php b/Moncter/app/repository/TagDefinitionRepository.php new file mode 100644 index 00000000..3f9d9b4e --- /dev/null +++ b/Moncter/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/Moncter/app/repository/TagHistoryRepository.php b/Moncter/app/repository/TagHistoryRepository.php new file mode 100644 index 00000000..9f24ed5e --- /dev/null +++ b/Moncter/app/repository/TagHistoryRepository.php @@ -0,0 +1,52 @@ + 'datetime', + ]; + + public $timestamps = false; +} + + diff --git a/Moncter/app/repository/TagTaskExecutionRepository.php b/Moncter/app/repository/TagTaskExecutionRepository.php new file mode 100644 index 00000000..a364592c --- /dev/null +++ b/Moncter/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/Moncter/app/repository/TagTaskRepository.php b/Moncter/app/repository/TagTaskRepository.php new file mode 100644 index 00000000..b883f4c6 --- /dev/null +++ b/Moncter/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/Moncter/app/repository/UserPhoneRelationRepository.php b/Moncter/app/repository/UserPhoneRelationRepository.php new file mode 100644 index 00000000..a214ee20 --- /dev/null +++ b/Moncter/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/Moncter/app/repository/UserProfileRepository.php b/Moncter/app/repository/UserProfileRepository.php new file mode 100644 index 00000000..3ab7ca8f --- /dev/null +++ b/Moncter/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/Moncter/app/repository/UserTagRepository.php b/Moncter/app/repository/UserTagRepository.php new file mode 100644 index 00000000..20d1cba3 --- /dev/null +++ b/Moncter/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/Moncter/app/service/ConsumptionService.php b/Moncter/app/service/ConsumptionService.php new file mode 100644 index 00000000..65d10786 --- /dev/null +++ b/Moncter/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/Moncter/app/service/DataCollection/Handler/BaseCollectionHandler.php b/Moncter/app/service/DataCollection/Handler/BaseCollectionHandler.php new file mode 100644 index 00000000..aa8a6422 --- /dev/null +++ b/Moncter/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/Moncter/app/service/DataCollection/Handler/ConsumptionCollectionHandler.php b/Moncter/app/service/DataCollection/Handler/ConsumptionCollectionHandler.php new file mode 100644 index 00000000..b4fd4037 --- /dev/null +++ b/Moncter/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/Moncter/app/service/DataCollection/Handler/DatabaseSyncHandler.php b/Moncter/app/service/DataCollection/Handler/DatabaseSyncHandler.php new file mode 100644 index 00000000..5df5943a --- /dev/null +++ b/Moncter/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/Moncter/app/service/DataCollection/Handler/GenericCollectionHandler.php b/Moncter/app/service/DataCollection/Handler/GenericCollectionHandler.php new file mode 100644 index 00000000..02791913 --- /dev/null +++ b/Moncter/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/Moncter/app/service/DataCollection/Handler/TagTaskHandler.php b/Moncter/app/service/DataCollection/Handler/TagTaskHandler.php new file mode 100644 index 00000000..27112ba2 --- /dev/null +++ b/Moncter/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/Moncter/app/service/DataCollection/Handler/Trait/DataCollectionHelperTrait.php b/Moncter/app/service/DataCollection/Handler/Trait/DataCollectionHelperTrait.php new file mode 100644 index 00000000..44ecfd6c --- /dev/null +++ b/Moncter/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/Moncter/app/service/DataCollectionTaskService.php b/Moncter/app/service/DataCollectionTaskService.php new file mode 100644 index 00000000..1c89a9b1 --- /dev/null +++ b/Moncter/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/Moncter/app/service/DataSource/Adapter/MongoDBAdapter.php b/Moncter/app/service/DataSource/Adapter/MongoDBAdapter.php new file mode 100644 index 00000000..b4f50858 --- /dev/null +++ b/Moncter/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/Moncter/app/service/DataSource/Adapter/MySQLAdapter.php b/Moncter/app/service/DataSource/Adapter/MySQLAdapter.php new file mode 100644 index 00000000..966ef98a --- /dev/null +++ b/Moncter/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/Moncter/app/service/DataSource/DataSourceAdapterFactory.php b/Moncter/app/service/DataSource/DataSourceAdapterFactory.php new file mode 100644 index 00000000..0935f65a --- /dev/null +++ b/Moncter/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/Moncter/app/service/DataSource/DataSourceAdapterInterface.php b/Moncter/app/service/DataSource/DataSourceAdapterInterface.php new file mode 100644 index 00000000..2d160585 --- /dev/null +++ b/Moncter/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/Moncter/app/service/DataSource/PollingStrategyFactory.php b/Moncter/app/service/DataSource/PollingStrategyFactory.php new file mode 100644 index 00000000..bb79b1e8 --- /dev/null +++ b/Moncter/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/Moncter/app/service/DataSource/PollingStrategyInterface.php b/Moncter/app/service/DataSource/PollingStrategyInterface.php new file mode 100644 index 00000000..fda21115 --- /dev/null +++ b/Moncter/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/Moncter/app/service/DataSource/Strategy/DefaultConsumptionStrategy.php b/Moncter/app/service/DataSource/Strategy/DefaultConsumptionStrategy.php new file mode 100644 index 00000000..dddf79c2 --- /dev/null +++ b/Moncter/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/Moncter/app/service/DataSource/Strategy/MongoDBConsumptionStrategy.php b/Moncter/app/service/DataSource/Strategy/MongoDBConsumptionStrategy.php new file mode 100644 index 00000000..d6cc4951 --- /dev/null +++ b/Moncter/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/Moncter/app/service/DataSourceService.php b/Moncter/app/service/DataSourceService.php new file mode 100644 index 00000000..b27396e3 --- /dev/null +++ b/Moncter/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/Moncter/app/service/DataSyncService.php b/Moncter/app/service/DataSyncService.php new file mode 100644 index 00000000..7c1ee3eb --- /dev/null +++ b/Moncter/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/Moncter/app/service/DatabaseSyncService.php b/Moncter/app/service/DatabaseSyncService.php new file mode 100644 index 00000000..8bf83292 --- /dev/null +++ b/Moncter/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/Moncter/app/service/IdentifierService.php b/Moncter/app/service/IdentifierService.php new file mode 100644 index 00000000..08eac2a0 --- /dev/null +++ b/Moncter/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/Moncter/app/service/PersonMergeService.php b/Moncter/app/service/PersonMergeService.php new file mode 100644 index 00000000..31ec6973 --- /dev/null +++ b/Moncter/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/Moncter/app/service/StoreService.php b/Moncter/app/service/StoreService.php new file mode 100644 index 00000000..3313bfa8 --- /dev/null +++ b/Moncter/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/Moncter/app/service/TagInitService.php b/Moncter/app/service/TagInitService.php new file mode 100644 index 00000000..28592d6b --- /dev/null +++ b/Moncter/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/Moncter/app/service/TagRuleEngine/SimpleRuleEngine.php b/Moncter/app/service/TagRuleEngine/SimpleRuleEngine.php new file mode 100644 index 00000000..98949b20 --- /dev/null +++ b/Moncter/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/Moncter/app/service/TagService.php b/Moncter/app/service/TagService.php new file mode 100644 index 00000000..af7b0af1 --- /dev/null +++ b/Moncter/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/Moncter/app/service/TagTaskExecutor.php b/Moncter/app/service/TagTaskExecutor.php new file mode 100644 index 00000000..9ed8503b --- /dev/null +++ b/Moncter/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/Moncter/app/service/TagTaskService.php b/Moncter/app/service/TagTaskService.php new file mode 100644 index 00000000..d628f384 --- /dev/null +++ b/Moncter/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/Moncter/app/service/UserPhoneService.php b/Moncter/app/service/UserPhoneService.php new file mode 100644 index 00000000..69c05997 --- /dev/null +++ b/Moncter/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/Moncter/app/service/UserService.php b/Moncter/app/service/UserService.php new file mode 100644 index 00000000..a02c4812 --- /dev/null +++ b/Moncter/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/Moncter/app/utils/ApiResponseHelper.php b/Moncter/app/utils/ApiResponseHelper.php new file mode 100644 index 00000000..e1433d04 --- /dev/null +++ b/Moncter/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/Moncter/app/utils/DataMaskingHelper.php b/Moncter/app/utils/DataMaskingHelper.php new file mode 100644 index 00000000..7d5d6400 --- /dev/null +++ b/Moncter/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/Moncter/app/utils/EncryptionHelper.php b/Moncter/app/utils/EncryptionHelper.php new file mode 100644 index 00000000..40cc7d91 --- /dev/null +++ b/Moncter/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/Moncter/app/utils/LogMaskingProcessor.php b/Moncter/app/utils/LogMaskingProcessor.php new file mode 100644 index 00000000..355125fa --- /dev/null +++ b/Moncter/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/Moncter/app/utils/LoggerHelper.php b/Moncter/app/utils/LoggerHelper.php new file mode 100644 index 00000000..113c82d7 --- /dev/null +++ b/Moncter/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/Moncter/app/utils/MongoDBHelper.php b/Moncter/app/utils/MongoDBHelper.php new file mode 100644 index 00000000..66f63200 --- /dev/null +++ b/Moncter/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/Moncter/app/utils/QueueService.php b/Moncter/app/utils/QueueService.php new file mode 100644 index 00000000..59451dff --- /dev/null +++ b/Moncter/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/Moncter/app/utils/RedisHelper.php b/Moncter/app/utils/RedisHelper.php new file mode 100644 index 00000000..78b621db --- /dev/null +++ b/Moncter/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/Moncter/config/data_collection_tasks.php b/Moncter/config/data_collection_tasks.php new file mode 100644 index 00000000..c0798226 --- /dev/null +++ b/Moncter/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/Moncter/config/data_sources.php b/Moncter/config/data_sources.php new file mode 100644 index 00000000..83b9ac73 --- /dev/null +++ b/Moncter/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/Moncter/config/encryption.php b/Moncter/config/encryption.php new file mode 100644 index 00000000..cac99334 --- /dev/null +++ b/Moncter/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/Moncter/config/queue.php b/Moncter/config/queue.php new file mode 100644 index 00000000..82ac9f40 --- /dev/null +++ b/Moncter/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/Moncter/public/database-sync-dashboard.html b/Moncter/public/database-sync-dashboard.html new file mode 100644 index 00000000..40920985 --- /dev/null +++ b/Moncter/public/database-sync-dashboard.html @@ -0,0 +1,1024 @@ + + + + + + 数据库同步进度看板 + + + +
+
+

📊 数据库同步进度看板

+
+ 加载中... + 最后更新: -- +
+
+ +
+
+
+
正在加载数据...
+
+
+ + +
+ + + + + diff --git a/Moncter/support/bootstrap/MongoDB.php b/Moncter/support/bootstrap/MongoDB.php new file mode 100644 index 00000000..f60c1306 --- /dev/null +++ b/Moncter/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(); + } + } +} + diff --git a/存客宝02.code-workspace b/存客宝02.code-workspace new file mode 100644 index 00000000..62fca6b9 --- /dev/null +++ b/存客宝02.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": "Moncter" + } + ] +} \ No newline at end of file