From 63950b18d1de048f12d3f18d2e579eed684cedaa Mon Sep 17 00:00:00 2001 From: jonathang4 Date: Mon, 25 Aug 2025 20:32:59 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8D=B3=E6=A2=A6=E9=80=86=E5=90=91=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=20=E5=A4=9A=E6=9C=8D=E5=8A=A1=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.template | 47 +++ Dockerfile | 16 + PM2_CONFIG_GUIDE.md | 199 +++++++++++ README.md | 114 +++++- TOS_UPLOAD_INTEGRATION.md | 155 ++++++++ configs/dev/service.yml | 6 +- deploy.sh | 196 +++++++++++ docker-compose.yml | 38 +- docker-compose.yml.template | 35 ++ ecosystem.config.json | 148 ++++++++ ecosystem.config.json.template | 148 ++++++++ multi-instance.sh | 312 ++++++++++++++++ package.json | 7 +- src/api/ImagesTaskCache.ts | 11 + src/api/VideoTaskCache.ts | 11 + src/api/routes/images.ts | 65 +++- src/api/routes/index.ts | 7 +- src/api/routes/services.ts | 203 +++++++++++ src/api/routes/video.ts | 65 +++- src/index.ts | 20 ++ src/lib/config.ts | 13 +- src/lib/configs/service-config.ts | 15 +- src/lib/database/models/ServiceHeartbeat.ts | 60 ++++ src/lib/database/mongodb.ts | 75 ++++ src/lib/services/HeartbeatService.ts | 275 +++++++++++++++ src/lib/tos/TOS_README.md | 239 +++++++++++++ src/lib/tos/tos-client.ts | 360 +++++++++++++++++++ src/lib/tos/tos-service.ts | 201 +++++++++++ src/lib/tos/tos-test.ts | 310 ++++++++++++++++ start-node.sh | 371 ++++++++++++++++++++ tsconfig.json | 5 +- yarn.lock | 236 ++++++++++++- 32 files changed, 3928 insertions(+), 35 deletions(-) create mode 100644 .env.template create mode 100644 PM2_CONFIG_GUIDE.md create mode 100644 TOS_UPLOAD_INTEGRATION.md create mode 100644 deploy.sh create mode 100644 docker-compose.yml.template create mode 100644 ecosystem.config.json create mode 100644 ecosystem.config.json.template create mode 100644 multi-instance.sh create mode 100644 src/api/routes/services.ts create mode 100644 src/lib/database/models/ServiceHeartbeat.ts create mode 100644 src/lib/database/mongodb.ts create mode 100644 src/lib/services/HeartbeatService.ts create mode 100644 src/lib/tos/TOS_README.md create mode 100644 src/lib/tos/tos-client.ts create mode 100644 src/lib/tos/tos-service.ts create mode 100644 src/lib/tos/tos-test.ts create mode 100644 start-node.sh diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..bf2b1c1 --- /dev/null +++ b/.env.template @@ -0,0 +1,47 @@ +# 即梦 Free API 环境变量配置模板 +# 复制此文件为 .env 并填入实际值,或直接在启动时设置环境变量 + +# =========================================== +# 基础服务配置 +# =========================================== +NODE_ENV=production +SERVICE_ID=jimeng-api-prod +SERVICE_NAME=jimeng-free-api-prod +HOST=0.0.0.0 +PORT=3302 + +# 可选:手动指定服务器基础URL,不设置会自动检测 +# BASE_URL=http://your-server-ip:3302 + +# =========================================== +# MongoDB 数据库配置 (必须设置) +# =========================================== +MONGODB_URL=mongodb://localhost:27017/jimeng-api + +# =========================================== +# 火山引擎 TOS 对象存储配置 (必须设置) +# =========================================== +TOS_ACCESS_KEY_ID=your_access_key_id +TOS_ACCESS_KEY_SECRET=your_access_key_secret +TOS_BUCKET_NAME=your_bucket_name + +# 可选配置,有默认值 +TOS_SELF_DOMAIN= +TOS_REGION=cn-beijing +TOS_ENDPOINT=tos-cn-beijing.volces.com + +# =========================================== +# 心跳服务配置 +# =========================================== +HEARTBEAT_ENABLED=true +HEARTBEAT_INTERVAL=30 + +# =========================================== +# 使用方法: +# =========================================== +# 1. 复制此文件: cp .env.template .env +# 2. 编辑 .env 文件,填入实际的配置值 +# 3. 启动服务: +# - PM2: pm2 start ecosystem.config.json --env production +# - Node.js: source .env && node dist/index.js +# - 脚本启动: ./start-node.sh prod \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c5fa99a..34e8f72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,12 @@ FROM node:20.19-slim +# 安装系统依赖和工具 +RUN apt-get update && apt-get install -y \ + curl \ + wget \ + procps \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app # Copy application dependency manifests to the container image. @@ -21,6 +28,15 @@ RUN ls -la /app RUN echo "Debug: Contents of /app/dist after build:" RUN ls -la /app/dist || echo "Debug: /app/dist directory not found or ls failed" +# 创建非 root 用户 +RUN groupadd -r nodeuser && useradd -r -g nodeuser nodeuser +RUN chown -R nodeuser:nodeuser /app +USER nodeuser + EXPOSE 3302 +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:3302/ping || exit 1 + CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/PM2_CONFIG_GUIDE.md b/PM2_CONFIG_GUIDE.md new file mode 100644 index 0000000..6e66ed5 --- /dev/null +++ b/PM2_CONFIG_GUIDE.md @@ -0,0 +1,199 @@ +# 通过 ecosystem.config.json 配置不同服务的示例 + +## 📋 基本原理 + +你可以完全通过修改 `ecosystem.config.json` 文件来配置不同数量和不同配置的服务,无需 `.env` 文件。 + +## 🎯 配置策略 + +### 1. 增加或减少服务数量 + +在 `apps` 数组中添加或删除服务配置: + +```json +{ + "apps": [ + // 保留需要的服务,删除不需要的 + { + "name": "jimeng-api-3302", + // ... 配置 + }, + { + "name": "jimeng-api-3303", + // ... 配置 + } + // 删除 jimeng-api-3304 或添加更多服务 + ] +} +``` + +### 2. 不同环境配置 + +每个服务都有 `env` (开发环境) 和 `env_production` (生产环境) 两套配置: + +```json +{ + "env": { + "MONGODB_URL": "mongodb://localhost:27017/jimeng-api", + "TOS_BUCKET_NAME": "dev-bucket" + }, + "env_production": { + "MONGODB_URL": "mongodb://prod-server:27017/jimeng-api", + "TOS_BUCKET_NAME": "prod-bucket" + } +} +``` + +### 3. 个性化服务配置 + +你可以为每个服务设置不同的配置: + +```json +{ + "apps": [ + { + "name": "jimeng-api-internal", + "env_production": { + "SERVICE_ID": "jimeng-internal", + "PORT": 3302, + "TOS_BUCKET_NAME": "internal-bucket", + "HEARTBEAT_INTERVAL": 30 + } + }, + { + "name": "jimeng-api-external", + "env_production": { + "SERVICE_ID": "jimeng-external", + "PORT": 3303, + "TOS_BUCKET_NAME": "external-bucket", + "HEARTBEAT_INTERVAL": 60 + } + } + ] +} +``` + +## 🚀 启动命令 + +### 启动所有服务 +```bash +pm2 start ecosystem.config.json --env production +``` + +### 启动单个服务 +```bash +pm2 start ecosystem.config.json --only jimeng-api-3302 --env production +``` + +### 启动多个指定服务 +```bash +pm2 start ecosystem.config.json --only "jimeng-api-3302,jimeng-api-3303" --env production +``` + +## 💡 实际配置示例 + +### 示例1: 2个服务,不同TOS配置 +```json +{ + "apps": [ + { + "name": "jimeng-api-images", + "env_production": { + "SERVICE_ID": "jimeng-images", + "PORT": 3302, + "TOS_BUCKET_NAME": "images-bucket", + "HEARTBEAT_INTERVAL": 30 + } + }, + { + "name": "jimeng-api-videos", + "env_production": { + "SERVICE_ID": "jimeng-videos", + "PORT": 3303, + "TOS_BUCKET_NAME": "videos-bucket", + "HEARTBEAT_INTERVAL": 60 + } + } + ] +} +``` + +### 示例2: 单个服务 +```json +{ + "apps": [ + { + "name": "jimeng-api-main", + "env_production": { + "SERVICE_ID": "jimeng-main", + "PORT": 3302, + "TOS_BUCKET_NAME": "main-bucket" + } + } + ] +} +``` + +### 示例3: 5个服务,负载分散 +```json +{ + "apps": [ + { + "name": "jimeng-api-3302", + "env_production": { "PORT": 3302, "SERVICE_ID": "jimeng-3302" } + }, + { + "name": "jimeng-api-3303", + "env_production": { "PORT": 3303, "SERVICE_ID": "jimeng-3303" } + }, + { + "name": "jimeng-api-3304", + "env_production": { "PORT": 3304, "SERVICE_ID": "jimeng-3304" } + }, + { + "name": "jimeng-api-3305", + "env_production": { "PORT": 3305, "SERVICE_ID": "jimeng-3305" } + }, + { + "name": "jimeng-api-3306", + "env_production": { "PORT": 3306, "SERVICE_ID": "jimeng-3306" } + } + ] +} +``` + +## ⚙️ 关键配置项说明 + +| 配置项 | 作用 | 示例 | +|--------|------|------| +| `name` | PM2进程名称 | `"jimeng-api-3302"` | +| `SERVICE_ID` | 心跳服务中的唯一标识 | `"jimeng-api-3302"` | +| `PORT` | 服务端口 | `3302` | +| `MONGODB_URL` | 数据库连接 | `"mongodb://localhost:27017/jimeng-api"` | +| `TOS_BUCKET_NAME` | 对象存储桶 | `"your-bucket"` | +| `HEARTBEAT_INTERVAL` | 心跳间隔(秒) | `30` | + +## 🔄 动态管理 + +### 修改配置后重新加载 +```bash +pm2 reload ecosystem.config.json --env production +``` + +### 只重启特定服务 +```bash +pm2 restart jimeng-api-3302 +``` + +### 删除服务后清理 +```bash +pm2 delete jimeng-api-3304 +``` + +## ✅ 优势 + +1. **集中管理** - 所有配置在一个文件中 +2. **版本控制** - 可以提交到Git中 +3. **环境隔离** - dev和production环境分离 +4. **灵活扩展** - 随时增减服务数量 +5. **配置差异** - 每个服务可以有不同配置 \ No newline at end of file diff --git a/README.md b/README.md index 9cbdcac..7328bf7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,118 @@ # jimeng-free-api -即梦api服务 +即梦api服务 - 服务器管理版本 + +## 新增功能 ✨ + +### 1. 服务器管理支持 +- 📊 支持Python项目的服务器管理架构 +- 💓 服务器心跳监控和状态上报 +- 🔄 自动注册和发现机制 + +### 2. 容器化部署 +- 🐳 简化的Docker配置,只管理服务自身 +- ⚙️ 环境变量配置,无需手动配置文件 +- 🛠️ 自动化部署脚本 + +### 3. API 接口优化 +- 🔍 适配Python项目的服务器查询接口 +- 📈 服务器状态统计和监控 + +## 快速开始 + +```bash +# 1. 安装依赖 +# 确保系统已安装 Docker 和 Docker Compose + +# 2. 启动服务 +./deploy.sh start + +# 3. 检查服务状态 +./deploy.sh status +``` + +## 环境变量配置 + +可以通过环境变量或docker-compose.yml中设置: + +### 必需配置 +- `MONGODB_URL`: MongoDB连接地址 +- `TOS_ACCESS_KEY_ID`: 火山引擎TOS访问密钥ID +- `TOS_ACCESS_KEY_SECRET`: 火山引擎TOS访问密钥 +- `TOS_BUCKET_NAME`: TOS存储桶名称 + +### 可选配置 +- `SERVICE_ID`: 服务器唯一标识(默认:jimeng-free-api) +- `SERVICE_NAME`: 服务器名称(默认:与SERVICE_ID相同) +- `HEARTBEAT_INTERVAL`: 心跳间隔秒数(默认:60) +- `API_PORT`: API服务端口(默认:3302) + +## 部署指令 + +```bash +# 启动服务 +./deploy.sh start + +# 停止服务 +./deploy.sh stop + +# 重启服务 +./deploy.sh restart + +# 查看状态 +./deploy.sh status + +# 查看日志 +./deploy.sh logs +./deploy.sh logs -f # 实时跟踪 + +# 重新构建 +./deploy.sh build +``` + +## API 接口 + +### 原有接口 +- `POST /v1/chat/completions` - OpenAI兼容的聊天接口 +- `POST /v1/images/generations` - 图像生成接口 +- `POST /v1/video/generations` - 视频生成接口 +- `GET /ping` - 健康检查接口 + +### 服务器管理接口 +- `GET /api/servers/current` - 获取当前服务器信息 +- `GET /api/servers/active` - 获取所有活跃服务器 +- `GET /api/servers/online` - 获取所有在线服务器 +- `GET /api/servers/stats` - 获取服务器状态统计 +- `GET /api/servers/:serverId` - 获取特定服务器详情 +- `POST /api/servers/cleanup` - 清理离线服务器记录 +- `POST /api/servers/:serverId/heartbeat` - 更新服务器心跳 + +### 服务状态查询示例 + +```bash +# 查看当前服务器信息 +curl http://localhost:3302/api/servers/current + +# 查看所有在线服务器 +curl http://localhost:3302/api/servers/online + +# 查看服务器统计 +curl http://localhost:3302/api/servers/stats +``` + +## 与Python项目集成 + +本服务专门适配`ai_jimeng3_handler.py`中的服务器管理架构: + +1. **数据库结构适配**: 使用与Python项目相同的`jimeng_servers`表结构 +2. **心跳机制**: 自动向数据库更新服务器心跳状态 +3. **负载均衡**: 支持Python项目的服务器选择和负载均衡策略 + +## 注意事项 + +- 本服务不包含MongoDB和Nginx,需要在外部配置 +- 数据库结构由Python项目统一管理 +- 必须正确配置环境变量后才能正常运行 ### 参考自 即梦接口转 API [kimi-free-api](https://github.com/LLM-Red-Team/jimeng-free-api) \ No newline at end of file diff --git a/TOS_UPLOAD_INTEGRATION.md b/TOS_UPLOAD_INTEGRATION.md new file mode 100644 index 0000000..e0cdd33 --- /dev/null +++ b/TOS_UPLOAD_INTEGRATION.md @@ -0,0 +1,155 @@ +# TOS上传集成说明 + +## 功能概述 + +现在图片和视频生成完成后,会自动将原始URL的文件上传到TOS对象存储,并返回TOS地址。 + +## 修改内容 + +### 1. 图片功能改进 + +**文件路径**: `src/api/routes/images.ts` + +- 增加了 `uploadImagesToTOS()` 函数 +- 修改了 `/query` 接口,当任务完成时自动处理TOS上传 +- 增加了缓存机制,避免重复上传 + +### 2. 视频功能改进 + +**文件路径**: `src/api/routes/video.ts` + +- 增加了 `uploadVideosToTOS()` 函数 +- 修改了 `/query` 接口,当任务完成时自动处理TOS上传 +- 增加了缓存机制,避免重复上传 + +### 3. 任务缓存改进 + +**文件路径**: +- `src/api/ImagesTaskCache.ts` +- `src/api/VideoTaskCache.ts` + +增加了TOS处理状态跟踪: +- `isTosProcessed(taskId)`: 检查任务是否已处理TOS上传 +- `markTosProcessed(taskId)`: 标记任务为已处理TOS上传 + +### 4. TOS服务配置 + +**文件路径**: `src/lib/tos/tos-service.ts` + +- 支持通过环境变量配置TOS参数 +- 增强了错误处理和日志记录 + +## 使用流程 + +### 图片生成流程 + +1. **发起生成请求** + ``` + POST /v1/images/generations + { + "task_id": "task_123", + "prompt": "一只可爱的猫", + "width": 1024, + "height": 1024 + } + ``` + +2. **查询任务状态** + ``` + GET /v1/images/query?task_id=task_123 + ``` + +3. **第一次查询**(任务完成时) + - 自动上传图片到TOS + - 返回TOS地址 + ```json + { + "created": 1703123456, + "data": { + "task_id": "task_123", + "url": "https://your-domain.com/images/image-1703123456-abc123.webp", + "status": -1 + } + } + ``` + +4. **后续查询** + - 直接返回缓存的TOS地址 + - 不会重复上传 + +### 视频生成流程 + +1. **发起生成请求** + ``` + POST /v1/video/generations + { + "task_id": "video_123", + "prompt": "一只猫在草地上奔跑", + "duration": 5, + "ratio": "16:9" + } + ``` + +2. **查询任务状态** + ``` + GET /v1/video/query?task_id=video_123 + ``` + +3. **处理流程同图片** + - 自动上传视频到TOS + - 返回TOS地址 + +## 环境变量配置 + +需要在环境变量中配置以下TOS参数: + +```env +# TOS对象存储配置 +TOS_ACCESS_KEY_ID=your_access_key_id +TOS_ACCESS_KEY_SECRET=your_access_key_secret +TOS_BUCKET_NAME=your_bucket_name +TOS_SELF_DOMAIN=your_custom_domain.com +TOS_REGION=cn-beijing +TOS_ENDPOINT=tos-cn-beijing.volces.com +``` + +## 文件存储路径 + +- **图片**: `images/image-{timestamp}-{random}.webp` +- **视频**: `videos/video-{timestamp}-{random}.mp4` + +## 错误处理 + +- 如果TOS上传失败,会返回原始URL +- 错误信息会记录到日志中 +- 不会影响原有功能的正常使用 + +## 日志记录 + +相关日志会记录在: +- `logs/images_task_cache.log` - 图片任务缓存日志 +- `logs/video_task_cache.log` - 视频任务缓存日志 +- 应用主日志 - TOS上传成功/失败日志 + +## 性能优化 + +1. **缓存机制**: 避免重复上传相同文件 +2. **异步处理**: TOS上传不阻塞API响应 +3. **错误兜底**: 上传失败时返回原始URL +4. **日志记录**: 便于监控和调试 + +## 注意事项 + +1. 确保TOS服务配置正确 +2. 确保网络可以访问TOS服务 +3. 监控TOS存储容量和费用 +4. 定期清理过期文件(可选) + +## 测试验证 + +可以通过以下方式验证功能: + +1. 生成一张图片,检查返回的URL是否为TOS地址 +2. 多次查询同一个task_id,确认不会重复上传 +3. 检查TOS存储中是否有对应的文件 +4. 测试TOS上传失败的情况下是否正常兜底 \ No newline at end of file diff --git a/configs/dev/service.yml b/configs/dev/service.yml index 5eb193e..73066b7 100644 --- a/configs/dev/service.yml +++ b/configs/dev/service.yml @@ -3,4 +3,8 @@ name: jimeng-free-api # 服务绑定主机地址 host: '0.0.0.0' # 服务绑定端口 -port: 3302 \ No newline at end of file +port: 3302 + +heartbeat: + enabled: true + interval: 30 \ No newline at end of file diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..c2622ba --- /dev/null +++ b/deploy.sh @@ -0,0 +1,196 @@ +#!/bin/bash + +# 即梦 Free API 部署脚本 +# 使用方法: ./deploy.sh [start|stop|restart|status|logs|build] [options] + +set -e + +# 颜色输出 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 日志函数 +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 检查依赖 +check_dependencies() { + log_info "检查系统依赖..." + + if ! command -v docker &> /dev/null; then + log_error "Docker 未安装,请先安装 Docker" + exit 1 + fi + + if ! command -v docker-compose &> /dev/null; then + log_error "Docker Compose 未安装,请先安装 Docker Compose" + exit 1 + fi + + log_success "依赖检查通过" +} + +# 构建镜像 +build_images() { + log_info "构建 Docker 镜像..." + docker-compose build --no-cache + log_success "镜像构建完成" +} + +# 启动服务 +start_service() { + log_info "启动即梦 Free API 服务..." + docker-compose up -d + log_success "服务启动完成" +} + +# 停止服务 +stop_service() { + log_info "停止服务..." + docker-compose down + log_success "服务已停止" +} + +# 重启服务 +restart_service() { + log_info "重启服务..." + docker-compose restart + log_success "服务重启完成" +} + +# 查看服务状态 +check_status() { + log_info "检查服务状态..." + + echo "=== Docker 容器状态 ===" + docker-compose ps + + echo + echo "=== 服务健康检查 ===" + + # 检查 API 服务 + CONTAINER_PORT=$(docker-compose port jimeng-free-api 3302 2>/dev/null | cut -d: -f2) + if [ -n "$CONTAINER_PORT" ]; then + if curl -s http://localhost:$CONTAINER_PORT/ping > /dev/null 2>&1; then + log_success "API 服务正常运行 (:$CONTAINER_PORT)" + + # 检查服务器心跳 + if curl -s http://localhost:$CONTAINER_PORT/api/servers/current > /dev/null 2>&1; then + echo + echo "=== 当前服务器信息 ===" + curl -s http://localhost:$CONTAINER_PORT/api/servers/current | jq '.data' 2>/dev/null || echo "需要安装 jq 来格式化 JSON 输出" + fi + else + log_error "API 服务无法访问" + fi + else + log_warning "API 服务端口未暴露或服务未运行" + fi +} + +# 查看日志 +view_logs() { + local follow=${1:-""} + + if [ "$follow" = "-f" ] || [ "$follow" = "--follow" ]; then + log_info "实时查看服务日志..." + docker-compose logs -f --tail=100 + else + log_info "查看服务日志..." + docker-compose logs --tail=100 + fi +} + +# 显示帮助信息 +show_help() { + echo "即梦 Free API 部署脚本" + echo + echo "使用方法:" + echo " $0 [options]" + echo + echo "命令:" + echo " start 启动服务" + echo " stop 停止服务" + echo " restart 重启服务" + echo " status 查看服务状态" + echo " logs [-f] 查看日志 (-f 实时跟踪)" + echo " build 仅构建镜像" + echo " help 显示此帮助信息" + echo + echo "示例:" + echo " $0 start # 启动服务" + echo " $0 logs -f # 实时查看日志" + echo " $0 status # 检查服务状态" + echo + echo "环境变量配置:" + echo " 请在 docker-compose.yml 中或系统环境变量中配置:" + echo " - MONGODB_URL: MongoDB 连接地址" + echo " - TOS_ACCESS_KEY_ID: TOS 访问密钥 ID" + echo " - TOS_ACCESS_KEY_SECRET: TOS 访问密钥" + echo " - TOS_BUCKET_NAME: TOS 存储桶名称" + echo " - SERVICE_ID: 服务器唯一标识" +} + +# 主函数 +main() { + local command="$1" + + case "$command" in + start) + check_dependencies + start_service + sleep 5 + check_status + ;; + stop) + stop_service + ;; + restart) + restart_service + sleep 5 + check_status + ;; + status) + check_status + ;; + logs) + view_logs "$2" + ;; + build) + check_dependencies + build_images + ;; + help|--help|-h) + show_help + ;; + "") + log_error "请指定命令" + show_help + exit 1 + ;; + *) + log_error "未知命令: $command" + show_help + exit 1 + ;; + esac +} + +# 执行主函数 +main "$@" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 81e0b69..5444c3d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,19 +1,35 @@ version: '3.8' services: - # jimeng-free-api: - # build: - # context: ./ - # dockerfile: Dockerfile - # image: jimeng-free-api:latest - # container_name: jimeng-free-api - # ports: - # - "8443:3302" - jimeng-free-api-pro: + # 即梦 API 服务 + jimeng-free-api: build: context: ./ dockerfile: Dockerfile image: jimeng-free-api:latest - container_name: jimeng-free-api-pro + container_name: jimeng-free-api + restart: unless-stopped + environment: + - NODE_ENV=production + - SERVICE_ID=${SERVICE_ID:-jimeng-free-api} + - INSTANCE_ID=${INSTANCE_ID:-instance-1} + - SERVICE_NAME=${SERVICE_NAME:-jimeng-free-api} + - MONGODB_URL=${MONGODB_URL:-mongodb://localhost:27017/jimeng-api} + - TOS_ACCESS_KEY_ID=${TOS_ACCESS_KEY_ID} + - TOS_ACCESS_KEY_SECRET=${TOS_ACCESS_KEY_SECRET} + - TOS_BUCKET_NAME=${TOS_BUCKET_NAME} + - TOS_SELF_DOMAIN=${TOS_SELF_DOMAIN} + - TOS_REGION=${TOS_REGION:-cn-beijing} + - TOS_ENDPOINT=${TOS_ENDPOINT:-tos-cn-beijing.volces.com} + - HEARTBEAT_ENABLED=${HEARTBEAT_ENABLED:-true} + - HEARTBEAT_INTERVAL=${HEARTBEAT_INTERVAL:-30} ports: - - "3300:3302" \ No newline at end of file + - "${API_PORT:-3302}:3302" + volumes: + - ./logs:/app/logs + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3302/ping"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s \ No newline at end of file diff --git a/docker-compose.yml.template b/docker-compose.yml.template new file mode 100644 index 0000000..5444c3d --- /dev/null +++ b/docker-compose.yml.template @@ -0,0 +1,35 @@ +version: '3.8' + +services: + # 即梦 API 服务 + jimeng-free-api: + build: + context: ./ + dockerfile: Dockerfile + image: jimeng-free-api:latest + container_name: jimeng-free-api + restart: unless-stopped + environment: + - NODE_ENV=production + - SERVICE_ID=${SERVICE_ID:-jimeng-free-api} + - INSTANCE_ID=${INSTANCE_ID:-instance-1} + - SERVICE_NAME=${SERVICE_NAME:-jimeng-free-api} + - MONGODB_URL=${MONGODB_URL:-mongodb://localhost:27017/jimeng-api} + - TOS_ACCESS_KEY_ID=${TOS_ACCESS_KEY_ID} + - TOS_ACCESS_KEY_SECRET=${TOS_ACCESS_KEY_SECRET} + - TOS_BUCKET_NAME=${TOS_BUCKET_NAME} + - TOS_SELF_DOMAIN=${TOS_SELF_DOMAIN} + - TOS_REGION=${TOS_REGION:-cn-beijing} + - TOS_ENDPOINT=${TOS_ENDPOINT:-tos-cn-beijing.volces.com} + - HEARTBEAT_ENABLED=${HEARTBEAT_ENABLED:-true} + - HEARTBEAT_INTERVAL=${HEARTBEAT_INTERVAL:-30} + ports: + - "${API_PORT:-3302}:3302" + volumes: + - ./logs:/app/logs + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3302/ping"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s \ No newline at end of file diff --git a/ecosystem.config.json b/ecosystem.config.json new file mode 100644 index 0000000..8133f84 --- /dev/null +++ b/ecosystem.config.json @@ -0,0 +1,148 @@ +{ + "apps": [ + { + "name": "jimeng-api-3302", + "script": "dist/index.js", + "instances": 1, + "exec_mode": "cluster", + "watch": false, + "max_memory_restart": "500M", + "env": { + "NODE_ENV": "development", + "SERVICE_ID": "jimeng-api-3302", + "SERVICE_NAME": "jimeng-free-api-3302", + "HOST": "0.0.0.0", + "PORT": 3302, + "MONGODB_URL": "mongodb://localhost:27017/jimeng-api", + "TOS_ACCESS_KEY_ID": "your_actual_access_key_id", + "TOS_ACCESS_KEY_SECRET": "your_actual_access_key_secret", + "TOS_BUCKET_NAME": "your_actual_bucket_name", + "TOS_SELF_DOMAIN": "", + "TOS_REGION": "cn-beijing", + "TOS_ENDPOINT": "tos-cn-beijing.volces.com", + "HEARTBEAT_ENABLED": true, + "HEARTBEAT_INTERVAL": 30 + }, + "env_production": { + "NODE_ENV": "production", + "SERVICE_ID": "jimeng-api-3302-prod", + "SERVICE_NAME": "jimeng-free-api-3302-prod", + "HOST": "0.0.0.0", + "PORT": 3302, + "MONGODB_URL": "mongodb://prod-server:27017/jimeng-api", + "TOS_ACCESS_KEY_ID": "prod_access_key_id", + "TOS_ACCESS_KEY_SECRET": "prod_access_key_secret", + "TOS_BUCKET_NAME": "prod_bucket_name", + "TOS_SELF_DOMAIN": "https://your-domain.com", + "TOS_REGION": "cn-beijing", + "TOS_ENDPOINT": "tos-cn-beijing.volces.com", + "HEARTBEAT_ENABLED": true, + "HEARTBEAT_INTERVAL": 30 + }, + "log_file": "./logs/combined-3302.log", + "out_file": "./logs/out-3302.log", + "error_file": "./logs/error-3302.log", + "log_date_format": "YYYY-MM-DD HH:mm:ss Z", + "merge_logs": true, + "autorestart": true, + "max_restarts": 10, + "min_uptime": "10s" + }, + { + "name": "jimeng-api-3303", + "script": "dist/index.js", + "instances": 1, + "exec_mode": "cluster", + "watch": false, + "max_memory_restart": "500M", + "env": { + "NODE_ENV": "development", + "SERVICE_ID": "jimeng-api-3303", + "SERVICE_NAME": "jimeng-free-api-3303", + "HOST": "0.0.0.0", + "PORT": 3303, + "MONGODB_URL": "mongodb://localhost:27017/jimeng-api", + "TOS_ACCESS_KEY_ID": "your_actual_access_key_id", + "TOS_ACCESS_KEY_SECRET": "your_actual_access_key_secret", + "TOS_BUCKET_NAME": "your_actual_bucket_name", + "TOS_SELF_DOMAIN": "", + "TOS_REGION": "cn-beijing", + "TOS_ENDPOINT": "tos-cn-beijing.volces.com", + "HEARTBEAT_ENABLED": true, + "HEARTBEAT_INTERVAL": 30 + }, + "env_production": { + "NODE_ENV": "production", + "SERVICE_ID": "jimeng-api-3303-prod", + "SERVICE_NAME": "jimeng-free-api-3303-prod", + "HOST": "0.0.0.0", + "PORT": 3303, + "MONGODB_URL": "mongodb://prod-server:27017/jimeng-api", + "TOS_ACCESS_KEY_ID": "prod_access_key_id", + "TOS_ACCESS_KEY_SECRET": "prod_access_key_secret", + "TOS_BUCKET_NAME": "prod_bucket_name", + "TOS_SELF_DOMAIN": "https://your-domain.com", + "TOS_REGION": "cn-beijing", + "TOS_ENDPOINT": "tos-cn-beijing.volces.com", + "HEARTBEAT_ENABLED": true, + "HEARTBEAT_INTERVAL": 30 + }, + "log_file": "./logs/combined-3303.log", + "out_file": "./logs/out-3303.log", + "error_file": "./logs/error-3303.log", + "log_date_format": "YYYY-MM-DD HH:mm:ss Z", + "merge_logs": true, + "autorestart": true, + "max_restarts": 10, + "min_uptime": "10s" + }, + { + "name": "jimeng-api-3304", + "script": "dist/index.js", + "instances": 1, + "exec_mode": "cluster", + "watch": false, + "max_memory_restart": "500M", + "env": { + "NODE_ENV": "development", + "SERVICE_ID": "jimeng-api-3304", + "SERVICE_NAME": "jimeng-free-api-3304", + "HOST": "0.0.0.0", + "PORT": 3304, + "MONGODB_URL": "mongodb://localhost:27017/jimeng-api", + "TOS_ACCESS_KEY_ID": "your_actual_access_key_id", + "TOS_ACCESS_KEY_SECRET": "your_actual_access_key_secret", + "TOS_BUCKET_NAME": "your_actual_bucket_name", + "TOS_SELF_DOMAIN": "", + "TOS_REGION": "cn-beijing", + "TOS_ENDPOINT": "tos-cn-beijing.volces.com", + "HEARTBEAT_ENABLED": true, + "HEARTBEAT_INTERVAL": 30 + }, + "env_production": { + "NODE_ENV": "production", + "SERVICE_ID": "jimeng-api-3304-prod", + "SERVICE_NAME": "jimeng-free-api-3304-prod", + "HOST": "0.0.0.0", + "PORT": 3304, + "MONGODB_URL": "mongodb://prod-server:27017/jimeng-api", + "TOS_ACCESS_KEY_ID": "prod_access_key_id", + "TOS_ACCESS_KEY_SECRET": "prod_access_key_secret", + "TOS_BUCKET_NAME": "prod_bucket_name", + "TOS_SELF_DOMAIN": "https://your-domain.com", + "TOS_REGION": "cn-beijing", + "TOS_ENDPOINT": "tos-cn-beijing.volces.com", + "HEARTBEAT_ENABLED": true, + "HEARTBEAT_INTERVAL": 30 + }, + "log_file": "./logs/combined-3304.log", + "out_file": "./logs/out-3304.log", + "error_file": "./logs/error-3304.log", + "log_date_format": "YYYY-MM-DD HH:mm:ss Z", + "merge_logs": true, + "autorestart": true, + "max_restarts": 10, + "min_uptime": "10s" + } + ] +} \ No newline at end of file diff --git a/ecosystem.config.json.template b/ecosystem.config.json.template new file mode 100644 index 0000000..8133f84 --- /dev/null +++ b/ecosystem.config.json.template @@ -0,0 +1,148 @@ +{ + "apps": [ + { + "name": "jimeng-api-3302", + "script": "dist/index.js", + "instances": 1, + "exec_mode": "cluster", + "watch": false, + "max_memory_restart": "500M", + "env": { + "NODE_ENV": "development", + "SERVICE_ID": "jimeng-api-3302", + "SERVICE_NAME": "jimeng-free-api-3302", + "HOST": "0.0.0.0", + "PORT": 3302, + "MONGODB_URL": "mongodb://localhost:27017/jimeng-api", + "TOS_ACCESS_KEY_ID": "your_actual_access_key_id", + "TOS_ACCESS_KEY_SECRET": "your_actual_access_key_secret", + "TOS_BUCKET_NAME": "your_actual_bucket_name", + "TOS_SELF_DOMAIN": "", + "TOS_REGION": "cn-beijing", + "TOS_ENDPOINT": "tos-cn-beijing.volces.com", + "HEARTBEAT_ENABLED": true, + "HEARTBEAT_INTERVAL": 30 + }, + "env_production": { + "NODE_ENV": "production", + "SERVICE_ID": "jimeng-api-3302-prod", + "SERVICE_NAME": "jimeng-free-api-3302-prod", + "HOST": "0.0.0.0", + "PORT": 3302, + "MONGODB_URL": "mongodb://prod-server:27017/jimeng-api", + "TOS_ACCESS_KEY_ID": "prod_access_key_id", + "TOS_ACCESS_KEY_SECRET": "prod_access_key_secret", + "TOS_BUCKET_NAME": "prod_bucket_name", + "TOS_SELF_DOMAIN": "https://your-domain.com", + "TOS_REGION": "cn-beijing", + "TOS_ENDPOINT": "tos-cn-beijing.volces.com", + "HEARTBEAT_ENABLED": true, + "HEARTBEAT_INTERVAL": 30 + }, + "log_file": "./logs/combined-3302.log", + "out_file": "./logs/out-3302.log", + "error_file": "./logs/error-3302.log", + "log_date_format": "YYYY-MM-DD HH:mm:ss Z", + "merge_logs": true, + "autorestart": true, + "max_restarts": 10, + "min_uptime": "10s" + }, + { + "name": "jimeng-api-3303", + "script": "dist/index.js", + "instances": 1, + "exec_mode": "cluster", + "watch": false, + "max_memory_restart": "500M", + "env": { + "NODE_ENV": "development", + "SERVICE_ID": "jimeng-api-3303", + "SERVICE_NAME": "jimeng-free-api-3303", + "HOST": "0.0.0.0", + "PORT": 3303, + "MONGODB_URL": "mongodb://localhost:27017/jimeng-api", + "TOS_ACCESS_KEY_ID": "your_actual_access_key_id", + "TOS_ACCESS_KEY_SECRET": "your_actual_access_key_secret", + "TOS_BUCKET_NAME": "your_actual_bucket_name", + "TOS_SELF_DOMAIN": "", + "TOS_REGION": "cn-beijing", + "TOS_ENDPOINT": "tos-cn-beijing.volces.com", + "HEARTBEAT_ENABLED": true, + "HEARTBEAT_INTERVAL": 30 + }, + "env_production": { + "NODE_ENV": "production", + "SERVICE_ID": "jimeng-api-3303-prod", + "SERVICE_NAME": "jimeng-free-api-3303-prod", + "HOST": "0.0.0.0", + "PORT": 3303, + "MONGODB_URL": "mongodb://prod-server:27017/jimeng-api", + "TOS_ACCESS_KEY_ID": "prod_access_key_id", + "TOS_ACCESS_KEY_SECRET": "prod_access_key_secret", + "TOS_BUCKET_NAME": "prod_bucket_name", + "TOS_SELF_DOMAIN": "https://your-domain.com", + "TOS_REGION": "cn-beijing", + "TOS_ENDPOINT": "tos-cn-beijing.volces.com", + "HEARTBEAT_ENABLED": true, + "HEARTBEAT_INTERVAL": 30 + }, + "log_file": "./logs/combined-3303.log", + "out_file": "./logs/out-3303.log", + "error_file": "./logs/error-3303.log", + "log_date_format": "YYYY-MM-DD HH:mm:ss Z", + "merge_logs": true, + "autorestart": true, + "max_restarts": 10, + "min_uptime": "10s" + }, + { + "name": "jimeng-api-3304", + "script": "dist/index.js", + "instances": 1, + "exec_mode": "cluster", + "watch": false, + "max_memory_restart": "500M", + "env": { + "NODE_ENV": "development", + "SERVICE_ID": "jimeng-api-3304", + "SERVICE_NAME": "jimeng-free-api-3304", + "HOST": "0.0.0.0", + "PORT": 3304, + "MONGODB_URL": "mongodb://localhost:27017/jimeng-api", + "TOS_ACCESS_KEY_ID": "your_actual_access_key_id", + "TOS_ACCESS_KEY_SECRET": "your_actual_access_key_secret", + "TOS_BUCKET_NAME": "your_actual_bucket_name", + "TOS_SELF_DOMAIN": "", + "TOS_REGION": "cn-beijing", + "TOS_ENDPOINT": "tos-cn-beijing.volces.com", + "HEARTBEAT_ENABLED": true, + "HEARTBEAT_INTERVAL": 30 + }, + "env_production": { + "NODE_ENV": "production", + "SERVICE_ID": "jimeng-api-3304-prod", + "SERVICE_NAME": "jimeng-free-api-3304-prod", + "HOST": "0.0.0.0", + "PORT": 3304, + "MONGODB_URL": "mongodb://prod-server:27017/jimeng-api", + "TOS_ACCESS_KEY_ID": "prod_access_key_id", + "TOS_ACCESS_KEY_SECRET": "prod_access_key_secret", + "TOS_BUCKET_NAME": "prod_bucket_name", + "TOS_SELF_DOMAIN": "https://your-domain.com", + "TOS_REGION": "cn-beijing", + "TOS_ENDPOINT": "tos-cn-beijing.volces.com", + "HEARTBEAT_ENABLED": true, + "HEARTBEAT_INTERVAL": 30 + }, + "log_file": "./logs/combined-3304.log", + "out_file": "./logs/out-3304.log", + "error_file": "./logs/error-3304.log", + "log_date_format": "YYYY-MM-DD HH:mm:ss Z", + "merge_logs": true, + "autorestart": true, + "max_restarts": 10, + "min_uptime": "10s" + } + ] +} \ No newline at end of file diff --git a/multi-instance.sh b/multi-instance.sh new file mode 100644 index 0000000..d16ff77 --- /dev/null +++ b/multi-instance.sh @@ -0,0 +1,312 @@ +#!/bin/bash + +# 即梦 Free API 多实例 PM2 管理脚本 +# 使用方法: ./multi-instance.sh [start|stop|restart|status|logs] [port1,port2,port3|all] + +set -e + +# 颜色输出 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 日志函数 +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 可用的端口列表 +AVAILABLE_PORTS=(3302 3303 3304) +ALL_APPS=("jimeng-api-3302" "jimeng-api-3303" "jimeng-api-3304") + +# 检查PM2 +check_pm2() { + if ! command -v pm2 &> /dev/null; then + log_error "PM2 未安装,请先安装 PM2" + log_info "npm install -g pm2" + exit 1 + fi +} + +# 构建项目 +build_project() { + if [ ! -d "dist" ]; then + log_info "构建项目..." + yarn install + yarn run build + log_success "项目构建完成" + fi +} + +# 解析端口参数 +parse_ports() { + local port_arg=$1 + local target_ports=() + + if [ "$port_arg" = "all" ] || [ -z "$port_arg" ]; then + target_ports=("${AVAILABLE_PORTS[@]}") + else + IFS=',' read -ra PORTS <<< "$port_arg" + for port in "${PORTS[@]}"; do + if [[ " ${AVAILABLE_PORTS[@]} " =~ " ${port} " ]]; then + target_ports+=("$port") + else + log_warning "无效端口: $port (可用端口: ${AVAILABLE_PORTS[*]})" + fi + done + fi + + echo "${target_ports[@]}" +} + +# 获取应用名称 +get_app_name() { + local port=$1 + echo "jimeng-api-${port}" +} + +# 启动指定端口的服务 +start_services() { + local ports_str=$1 + local env_mode=${2:-production} + local target_ports=($(parse_ports "$ports_str")) + + if [ ${#target_ports[@]} -eq 0 ]; then + log_error "没有有效的端口" + return 1 + fi + + log_info "启动服务实例..." + build_project + + for port in "${target_ports[@]}"; do + local app_name=$(get_app_name "$port") + log_info "启动 $app_name (端口 $port)..." + + # 停止现有实例 + pm2 delete "$app_name" 2>/dev/null || true + + # 启动新实例 + if [ "$env_mode" = "production" ]; then + pm2 start ecosystem.config.json --only "$app_name" --env production + else + pm2 start ecosystem.config.json --only "$app_name" + fi + + # 等待启动完成 + sleep 2 + + # 检查启动状态 + if pm2 list | grep -q "$app_name.*online"; then + log_success "$app_name 启动成功 (http://localhost:$port)" + else + log_error "$app_name 启动失败" + fi + done + + pm2 save + log_success "所有服务启动完成" +} + +# 停止指定端口的服务 +stop_services() { + local ports_str=$1 + local target_ports=($(parse_ports "$ports_str")) + + if [ ${#target_ports[@]} -eq 0 ]; then + log_error "没有有效的端口" + return 1 + fi + + log_info "停止服务实例..." + + for port in "${target_ports[@]}"; do + local app_name=$(get_app_name "$port") + log_info "停止 $app_name..." + pm2 delete "$app_name" 2>/dev/null || log_warning "$app_name 不存在或已停止" + done + + log_success "服务停止完成" +} + +# 重启服务 +restart_services() { + local ports_str=$1 + local env_mode=${2:-production} + + log_info "重启服务..." + stop_services "$ports_str" + sleep 2 + start_services "$ports_str" "$env_mode" +} + +# 查看服务状态 +check_status() { + local ports_str=$1 + local target_ports=($(parse_ports "$ports_str")) + + echo "=== PM2 进程列表 ===" + pm2 list + + echo + echo "=== 服务详细状态 ===" + + if [ ${#target_ports[@]} -eq 0 ]; then + target_ports=("${AVAILABLE_PORTS[@]}") + fi + + for port in "${target_ports[@]}"; do + local app_name=$(get_app_name "$port") + echo "--- $app_name (端口 $port) ---" + + if pm2 list | grep -q "$app_name"; then + local status=$(pm2 list | grep "$app_name" | awk '{print $10}')\n log_info "状态: $status" + + # 健康检查 + if curl -s "http://localhost:$port/ping" > /dev/null 2>&1; then + log_success "健康检查: 正常" + + # 获取服务器信息 + local server_info=$(curl -s "http://localhost:$port/api/servers/current" 2>/dev/null || echo "{}")\n if [ "$server_info" != "{}" ]; then + log_info "服务器信息: $(echo "$server_info" | jq -r '.data.server_id // "unknown"' 2>/dev/null || echo "获取失败")" + fi + else + log_error "健康检查: 失败" + fi + else + log_warning "进程不存在" + fi + echo + done + + echo "=== 端口占用情况 ===" + for port in "${target_ports[@]}"; do + if netstat -tlnp 2>/dev/null | grep ":$port " > /dev/null; then + log_success "端口 $port: 已占用" + else + log_warning "端口 $port: 未占用" + fi + done +} + +# 查看日志 +view_logs() { + local ports_str=$1 + local target_ports=($(parse_ports "$ports_str")) + + if [ ${#target_ports[@]} -eq 0 ]; then + log_error "没有有效的端口" + return 1 + fi + + if [ ${#target_ports[@]} -eq 1 ]; then + # 单个服务,显示实时日志 + local app_name=$(get_app_name "${target_ports[0]}") + log_info "查看 $app_name 实时日志..." + pm2 logs "$app_name" --lines 50 + else + # 多个服务,显示最近日志 + for port in "${target_ports[@]}"; do + local app_name=$(get_app_name "$port") + echo "=== $app_name 最近日志 ===" + pm2 logs "$app_name" --lines 20 --nostream + echo + done + fi +} + +# 显示帮助 +show_help() { + echo "即梦 Free API 多实例 PM2 管理脚本" + echo + echo "使用方法:" + echo " $0 [ports] [env]" + echo + echo "命令:" + echo " start 启动服务实例" + echo " stop 停止服务实例" + echo " restart 重启服务实例" + echo " status 查看服务状态" + echo " logs 查看服务日志" + echo " help 显示此帮助信息" + echo + echo "端口参数:" + echo " all 所有端口 (默认)" + echo " 3302 仅端口 3302" + echo " 3302,3303 多个端口,用逗号分隔" + echo " 可用端口: ${AVAILABLE_PORTS[*]}" + echo + echo "环境参数 (仅 start/restart):" + echo " production 生产环境 (默认)" + echo " development 开发环境" + echo + echo "示例:" + echo " $0 start all production # 启动所有实例 (生产环境)" + echo " $0 start 3302,3303 development # 启动端口 3302 和 3303 (开发环境)" + echo " $0 stop 3304 # 停止端口 3304" + echo " $0 restart all # 重启所有实例" + echo " $0 status # 查看所有实例状态" + echo " $0 logs 3302 # 查看端口 3302 的日志" + echo + echo "服务访问地址:" + for port in "${AVAILABLE_PORTS[@]}"; do + echo " - http://localhost:$port (jimeng-api-$port)" + done +} + +# 主函数 +main() { + local command="$1" + local ports="$2" + local env_mode="${3:-production}" + + check_pm2 + + case "$command" in + start) + start_services "$ports" "$env_mode" + ;; + stop) + stop_services "$ports" + ;; + restart) + restart_services "$ports" "$env_mode" + ;; + status) + check_status "$ports" + ;; + logs) + view_logs "$ports" + ;; + help|--help|-h) + show_help + ;; + "") + log_error "请指定命令" + show_help + exit 1 + ;; + *) + log_error "未知命令: $command" + show_help + exit 1 + ;; + esac +} + +# 执行主函数 +main "$@" \ No newline at end of file diff --git a/package.json b/package.json index fbfd3c8..848bd92 100644 --- a/package.json +++ b/package.json @@ -41,11 +41,16 @@ "minimist": "^1.2.8", "randomstring": "^1.3.0", "uuid": "^9.0.1", - "yaml": "^2.3.4" + "yaml": "^2.3.4", + "@volcengine/tos-sdk": "^2.6.7", + "mime-types": "^2.1.35", + "mongoose": "^8.0.3", + "node-cron": "^3.0.3" }, "devDependencies": { "@types/lodash": "^4.14.202", "@types/mime": "^3.0.4", + "@types/node-cron": "^3.0.11", "tsup": "^8.0.2", "typescript": "^5.3.3" } diff --git a/src/api/ImagesTaskCache.ts b/src/api/ImagesTaskCache.ts index 1202b8c..85329ed 100644 --- a/src/api/ImagesTaskCache.ts +++ b/src/api/ImagesTaskCache.ts @@ -22,9 +22,11 @@ function cacheLog(value: string, color?: string) { export class ImagesTaskCache { private static instance: ImagesTaskCache; private taskCache: Map; + private tosProcessedTasks: Set; // 记录已处理TOS上传的任务 private constructor() { this.taskCache = new Map(); + this.tosProcessedTasks = new Set(); cacheLog("ImagesTaskCache initialized"); } @@ -67,6 +69,15 @@ export class ImagesTaskCache { return this.taskCache.get(taskId); } + public isTosProcessed(taskId: string): boolean { + return this.tosProcessedTasks.has(taskId); + } + + public markTosProcessed(taskId: string): void { + this.tosProcessedTasks.add(taskId); + cacheLog(`Task ${taskId} marked as TOS processed`); + } + public getPendingTasks(): string[] { const pendingTasks: string[] = []; for (const [taskId, status] of this.taskCache.entries()) { diff --git a/src/api/VideoTaskCache.ts b/src/api/VideoTaskCache.ts index d20b81a..cd44ddc 100644 --- a/src/api/VideoTaskCache.ts +++ b/src/api/VideoTaskCache.ts @@ -22,9 +22,11 @@ function cacheLog(value: string, color?: string) { export class VideoTaskCache { private static instance: VideoTaskCache; private taskCache: Map; + private tosProcessedTasks: Set; // 记录已处理TOS上传的任务 private constructor() { this.taskCache = new Map(); + this.tosProcessedTasks = new Set(); cacheLog("VideoTaskCache initialized"); } @@ -67,6 +69,15 @@ export class VideoTaskCache { return this.taskCache.get(taskId); } + public isTosProcessed(taskId: string): boolean { + return this.tosProcessedTasks.has(taskId); + } + + public markTosProcessed(taskId: string): void { + this.tosProcessedTasks.add(taskId); + cacheLog(`Task ${taskId} marked as TOS processed`); + } + public getPendingTasks(): string[] { const pendingTasks: string[] = []; for (const [taskId, status] of this.taskCache.entries()) { diff --git a/src/api/routes/images.ts b/src/api/routes/images.ts index 33c919d..6a349f2 100644 --- a/src/api/routes/images.ts +++ b/src/api/routes/images.ts @@ -5,6 +5,34 @@ import { generateImages } from "@/api/controllers/images.ts"; import { tokenSplit } from "@/api/controllers/core.ts"; import util from "@/lib/util.ts"; import { ImagesTaskCache } from '@/api/ImagesTaskCache.ts'; +import TOSService from "@/lib/tos/tos-service.ts"; +import logger from "@/lib/logger.ts"; + +/** + * 处理图片URL上传到TOS + * @param imageUrls 图片URL数组 + * @returns TOS URL数组 + */ +async function uploadImagesToTOS(imageUrls: string[]): Promise { + const tosUrls: string[] = []; + + for (const imageUrl of imageUrls) { + try { + // 从URL获取文件名 + const fileName = `image-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.webp`; + // 上传到TOS + const tosUrl = await TOSService.uploadFromUrl(imageUrl, `images/${fileName}`); + tosUrls.push(tosUrl); + logger.info(`图片上传到TOS成功: ${imageUrl} -> ${tosUrl}`); + } catch (error) { + logger.error(`图片上传到TOS失败: ${imageUrl}`, error); + // 如果上传失败,保留原URL + tosUrls.push(imageUrl); + } + } + + return tosUrls; +} export default { prefix: "/v1/images", @@ -18,11 +46,38 @@ export default { } = request.query; // 从 query 中获取 let res = imagesTaskCache.getTaskStatus(task_id); // console.log("查询任务状态", task_id, 'res:',res); - if(typeof res === 'string'){ - return { - created: util.unixTimestamp(), - data:{task_id, url:res, status:-1}, - }; + if(typeof res === 'string'){ + // 任务已完成,检查是否已处理TOS上传 + if (!imagesTaskCache.isTosProcessed(task_id)) { + // 尚未处理TOS上传,处理图片URL上传到TOS + try { + const imageUrls = res.split(','); + const tosUrls = await uploadImagesToTOS(imageUrls); + const tosUrlsString = tosUrls.join(','); + + // 更新缓存中TOS URL并标记为已处理 + imagesTaskCache.finishTask(task_id, -1, tosUrlsString); + imagesTaskCache.markTosProcessed(task_id); + + return { + created: util.unixTimestamp(), + data:{task_id, url: tosUrlsString, status:-1}, + }; + } catch (error) { + logger.error(`处理图片TOS上传失败: ${task_id}`, error); + // 如果上传失败,返回原始URL + return { + created: util.unixTimestamp(), + data:{task_id, url:res, status:-1}, + }; + } + } else { + // 已处理TOS上传,直接返回缓存的TOS URL + return { + created: util.unixTimestamp(), + data:{task_id, url:res, status:-1}, + }; + } }else{ return { created: util.unixTimestamp(), diff --git a/src/api/routes/index.ts b/src/api/routes/index.ts index 03de020..306faeb 100644 --- a/src/api/routes/index.ts +++ b/src/api/routes/index.ts @@ -8,6 +8,7 @@ import token from './token.js'; // import models from './models.ts'; import upload from './upload.ts'; import video from './video.ts'; +import servers from './services.ts'; export default [ { @@ -29,5 +30,9 @@ export default [ token, // models, video, - upload + upload, + { + prefix: '/api', + ...servers + } ]; \ No newline at end of file diff --git a/src/api/routes/services.ts b/src/api/routes/services.ts new file mode 100644 index 0000000..b5dd0c2 --- /dev/null +++ b/src/api/routes/services.ts @@ -0,0 +1,203 @@ +import Response from '@/lib/response/Response.ts'; +import { HeartbeatService } from '@/lib/services/HeartbeatService.ts'; +import JimengServer from '@/lib/database/models/ServiceHeartbeat.ts'; +import logger from '@/lib/logger.ts'; + +export default { + // 获取当前服务器信息 + get: { + '/servers/current': async () => { + try { + const heartbeatService = HeartbeatService.getInstance(); + const serverInfo = heartbeatService.getServerInfo(); + + return new Response({ + success: true, + data: serverInfo + }); + } catch (error) { + logger.error('Failed to get current server info:', error); + return new Response({ + success: false, + error: error.message + }, { statusCode: 500 }); + } + }, + + // 获取所有活跃服务器 + '/servers/active': async () => { + try { + const activeServers = await HeartbeatService.getActiveServers(); + + return new Response({ + success: true, + data: activeServers, + total: activeServers.length + }); + } catch (error) { + logger.error('Failed to get active servers:', error); + return new Response({ + success: false, + error: error.message + }, { statusCode: 500 }); + } + }, + + // 获取所有在线服务器(基于心跳超时检查) + '/servers/online': async () => { + try { + const onlineServers = await HeartbeatService.getOnlineServers(); + + return new Response({ + success: true, + data: onlineServers, + total: onlineServers.length + }); + } catch (error) { + logger.error('Failed to get online servers:', error); + return new Response({ + success: false, + error: error.message + }, { statusCode: 500 }); + } + }, + + // 获取服务器状态统计 + '/servers/stats': async () => { + try { + const stats = await JimengServer.aggregate([ + { + $group: { + _id: null, + totalServers: { $sum: 1 }, + activeServers: { + $sum: { + $cond: [{ $eq: ['$is_active', true] }, 1, 0] + } + }, + avgHeartbeatInterval: { $avg: '$heartbeat_interval' } + } + } + ]); + + const currentTime = Math.floor(Date.now() / 1000); + const onlineServers = await JimengServer.countDocuments({ + is_active: true, + $expr: { + $lte: [ + { $subtract: [currentTime, '$last_heartbeat'] }, + { $multiply: ['$heartbeat_interval', 1.5] } + ] + } + }); + + const result = stats[0] || { totalServers: 0, activeServers: 0, avgHeartbeatInterval: 0 }; + result.onlineServers = onlineServers; + result.healthRate = result.totalServers > 0 ? + ((onlineServers / result.totalServers) * 100).toFixed(2) + '%' : '0%'; + + return new Response({ + success: true, + data: result + }); + } catch (error) { + logger.error('Failed to get server stats:', error); + return new Response({ + success: false, + error: error.message + }, { statusCode: 500 }); + } + }, + + // 获取特定服务器详情 + '/servers/:serverId': async (request) => { + try { + const serverId = request.params.serverId; + const server = await JimengServer.findOne({ server_id: serverId }); + + if (!server) { + return new Response({ + success: false, + error: 'Server not found' + }, { statusCode: 404 }); + } + + // 检查服务器是否在线 + const currentTime = Math.floor(Date.now() / 1000); + const heartbeatTimeout = server.heartbeat_interval * 1.5; + const isOnline = server.is_active && (currentTime - server.last_heartbeat) <= heartbeatTimeout; + + return new Response({ + success: true, + data: { + ...server.toObject(), + isOnline + } + }); + } catch (error) { + logger.error('Failed to get server details:', error); + return new Response({ + success: false, + error: error.message + }, { statusCode: 500 }); + } + } + }, + + post: { + // 手动清理离线服务器 + '/servers/cleanup': async () => { + try { + await HeartbeatService.cleanupOfflineServers(); + + return new Response({ + success: true, + message: 'Offline servers cleaned up successfully' + }); + } catch (error) { + logger.error('Failed to cleanup offline servers:', error); + return new Response({ + success: false, + error: error.message + }, { statusCode: 500 }); + } + }, + + // 更新服务器心跳(为Python项目提供) + '/servers/:serverId/heartbeat': async (request) => { + try { + const serverId = request.params.serverId; + const currentTime = Math.floor(Date.now() / 1000); + + const result = await JimengServer.findOneAndUpdate( + { server_id: serverId }, + { + last_heartbeat: currentTime, + updated_at: currentTime, + is_active: true + }, + { new: true } + ); + + if (!result) { + return new Response({ + success: false, + error: 'Server not found' + }, { statusCode: 404 }); + } + + return new Response({ + success: true, + message: 'Heartbeat updated successfully', + data: result + }); + } catch (error) { + logger.error('Failed to update server heartbeat:', error); + return new Response({ + success: false, + error: error.message + }, { statusCode: 500 }); + } + } + } +}; \ No newline at end of file diff --git a/src/api/routes/video.ts b/src/api/routes/video.ts index c5b8d9a..6ffe46a 100644 --- a/src/api/routes/video.ts +++ b/src/api/routes/video.ts @@ -5,6 +5,34 @@ import { generateVideo, upgradeVideoResolution } from "@/api/controllers/video.t import { tokenSplit } from "@/api/controllers/core.ts"; import util from "@/lib/util.ts"; import { VideoTaskCache } from '@/api/VideoTaskCache.ts'; +import TOSService from "@/lib/tos/tos-service.ts"; +import logger from "@/lib/logger.ts"; + +/** + * 处理视频URL上传到TOS + * @param videoUrls 视频URL数组 + * @returns TOS URL数组 + */ +async function uploadVideosToTOS(videoUrls: string[]): Promise { + const tosUrls: string[] = []; + + for (const videoUrl of videoUrls) { + try { + // 从URL获取文件名 + const fileName = `video-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.mp4`; + // 上传到TOS + const tosUrl = await TOSService.uploadFromUrl(videoUrl, `videos/${fileName}`); + tosUrls.push(tosUrl); + logger.info(`视频上传到TOS成功: ${videoUrl} -> ${tosUrl}`); + } catch (error) { + logger.error(`视频上传到TOS失败: ${videoUrl}`, error); + // 如果上传失败,保留原URL + tosUrls.push(videoUrl); + } + } + + return tosUrls; +} export default { prefix: "/v1/video", @@ -18,11 +46,38 @@ export default { } = request.query; // 从 query 中获取 let res = videoTaskCache.getTaskStatus(task_id); // console.log("查询任务状态", task_id, 'res:',res); - if(typeof res === 'string'){ - return { - created: util.unixTimestamp(), - data:{task_id, url:res, status:-1}, - }; + if(typeof res === 'string'){ + // 任务已完成,检查是否已处理TOS上传 + if (!videoTaskCache.isTosProcessed(task_id)) { + // 尚未处理TOS上传,处理视频URL上传到TOS + try { + const videoUrls = res.split(','); + const tosUrls = await uploadVideosToTOS(videoUrls); + const tosUrlsString = tosUrls.join(','); + + // 更新缓存中TOS URL并标记为已处理 + videoTaskCache.finishTask(task_id, -1, tosUrlsString); + videoTaskCache.markTosProcessed(task_id); + + return { + created: util.unixTimestamp(), + data:{task_id, url: tosUrlsString, status:-1}, + }; + } catch (error) { + logger.error(`处理视频TOS上传失败: ${task_id}`, error); + // 如果上传失败,返回原始URL + return { + created: util.unixTimestamp(), + data:{task_id, url:res, status:-1}, + }; + } + } else { + // 已处理TOS上传,直接返回缓存的TOS URL + return { + created: util.unixTimestamp(), + data:{task_id, url:res, status:-1}, + }; + } }else{ return { created: util.unixTimestamp(), diff --git a/src/index.ts b/src/index.ts index 67618d2..8f38768 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,8 @@ import "@/lib/initialize.ts"; import server from "@/lib/server.ts"; import routes from "@/api/routes/index.ts"; import logger from "@/lib/logger.ts"; +import mongoDBManager from "@/lib/database/mongodb.ts"; +import heartbeatService from "@/lib/services/HeartbeatService.ts"; const startupTime = performance.now(); @@ -18,9 +20,27 @@ const startupTime = performance.now(); logger.info("Environment:", environment.env); logger.info("Service name:", config.service.name); + // 初始化MongoDB连接 + try { + await mongoDBManager.connect(); + logger.success("MongoDB connected successfully"); + } catch (error) { + logger.warn("MongoDB connection failed, continuing without database:", error.message); + } + server.attachRoutes(routes); await server.listen(); + // 启动心跳服务 + if (config.heartbeat?.enabled !== false && mongoDBManager.isMongoConnected()) { + try { + await heartbeatService.start(); + logger.success("Heartbeat service started"); + } catch (error) { + logger.warn("Failed to start heartbeat service:", error.message); + } + } + config.service.bindAddress && logger.success("Service bind address:", config.service.bindAddress); })() diff --git a/src/lib/config.ts b/src/lib/config.ts index b6072d2..08a5d14 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,14 +1,23 @@ -import serviceConfig from "./configs/service-config.ts"; +import serviceConfig, { ServiceConfig } from "./configs/service-config.ts"; import systemConfig from "./configs/system-config.ts"; class Config { /** 服务配置 */ - service = serviceConfig; + service: ServiceConfig = serviceConfig; /** 系统配置 */ system = systemConfig; + // 代理属性以便直接访问心跳和数据库配置 + get heartbeat() { + return this.service.heartbeat; + } + + get database() { + return this.service.database; + } + } export default new Config(); \ No newline at end of file diff --git a/src/lib/configs/service-config.ts b/src/lib/configs/service-config.ts index 3419a8f..7c45bf2 100644 --- a/src/lib/configs/service-config.ts +++ b/src/lib/configs/service-config.ts @@ -24,14 +24,27 @@ export class ServiceConfig { urlPrefix; /** @type {string} 服务绑定地址(外部访问地址) */ bindAddress; + /** 数据库配置 */ + database?: { + mongodb?: { + url: string; + }; + }; + /** 心跳配置 */ + heartbeat?: { + enabled: boolean; + interval: number; + }; constructor(options?: any) { - const { name, host, port, urlPrefix, bindAddress } = options || {}; + const { name, host, port, urlPrefix, bindAddress, database, heartbeat } = options || {}; this.name = _.defaultTo(name, 'jimeng-free-api'); this.host = _.defaultTo(host, '0.0.0.0'); this.port = _.defaultTo(port, 5566); this.urlPrefix = _.defaultTo(urlPrefix, ''); this.bindAddress = bindAddress; + this.database = database; + this.heartbeat = _.defaultTo(heartbeat, { enabled: true, interval: 30 }); } get addressHost() { diff --git a/src/lib/database/models/ServiceHeartbeat.ts b/src/lib/database/models/ServiceHeartbeat.ts new file mode 100644 index 0000000..157aa9a --- /dev/null +++ b/src/lib/database/models/ServiceHeartbeat.ts @@ -0,0 +1,60 @@ +import mongoose, { Schema, Document } from 'mongoose'; + +// 即梦服务器配置表数据模型 - 适配Python项目 +export interface IJimengServer extends Document { + server_id: string; // 服务器唯一标识 + server_name: string; // 服务器名称 + base_url: string; // 服务器基础URL + is_active: boolean; // 管理员控制开关 + last_heartbeat: number; // 最后心跳时间戳(秒) + heartbeat_interval: number; // 心跳间隔(秒) + created_at: number; // 创建时间戳(秒) + updated_at: number; // 更新时间戳(秒) +} + +const JimengServerSchema: Schema = new Schema({ + server_id: { + type: String, + required: true, + unique: true, + index: true + }, + server_name: { + type: String, + required: true + }, + base_url: { + type: String, + required: true + }, + is_active: { + type: Boolean, + default: true + }, + last_heartbeat: { + type: Number, + default: 0, + index: true + }, + heartbeat_interval: { + type: Number, + default: 60 + }, + created_at: { + type: Number, + default: () => Math.floor(Date.now() / 1000) + }, + updated_at: { + type: Number, + default: () => Math.floor(Date.now() / 1000) + } +}, { + collection: 'jimeng_servers', + timestamps: false // 使用自定义时间戳 +}); + +// 创建索引 +JimengServerSchema.index({ server_id: 1 }); +JimengServerSchema.index({ is_active: 1, last_heartbeat: 1 }); + +export default mongoose.model('JimengServer', JimengServerSchema); \ No newline at end of file diff --git a/src/lib/database/mongodb.ts b/src/lib/database/mongodb.ts new file mode 100644 index 0000000..810244d --- /dev/null +++ b/src/lib/database/mongodb.ts @@ -0,0 +1,75 @@ +import mongoose from 'mongoose'; +import logger from '@/lib/logger.ts'; +import config from '@/lib/config.ts'; + +class MongoDBManager { + private static instance: MongoDBManager; + private isConnected: boolean = false; + + private constructor() {} + + public static getInstance(): MongoDBManager { + if (!MongoDBManager.instance) { + MongoDBManager.instance = new MongoDBManager(); + } + return MongoDBManager.instance; + } + + public async connect(): Promise { + try { + const mongoUrl = process.env.MONGODB_URL || config.database?.mongodb?.url || 'mongodb://localhost:27017/jimeng-api'; + + await mongoose.connect(mongoUrl, { + maxPoolSize: 10, + serverSelectionTimeoutMS: 5000, + socketTimeoutMS: 45000, + bufferCommands: false + }); + + this.isConnected = true; + logger.success('MongoDB connected successfully'); + + // 监听连接事件 + mongoose.connection.on('error', (err) => { + logger.error('MongoDB connection error:', err); + this.isConnected = false; + }); + + mongoose.connection.on('disconnected', () => { + logger.warn('MongoDB disconnected'); + this.isConnected = false; + }); + + mongoose.connection.on('reconnected', () => { + logger.success('MongoDB reconnected'); + this.isConnected = true; + }); + + } catch (error) { + logger.error('MongoDB connection failed:', error); + this.isConnected = false; + throw error; + } + } + + public async disconnect(): Promise { + try { + await mongoose.disconnect(); + this.isConnected = false; + logger.info('MongoDB disconnected'); + } catch (error) { + logger.error('MongoDB disconnect error:', error); + throw error; + } + } + + public isMongoConnected(): boolean { + return this.isConnected && mongoose.connection.readyState === 1; + } + + public getConnection() { + return mongoose.connection; + } +} + +export default MongoDBManager.getInstance(); \ No newline at end of file diff --git a/src/lib/services/HeartbeatService.ts b/src/lib/services/HeartbeatService.ts new file mode 100644 index 0000000..0cacdcc --- /dev/null +++ b/src/lib/services/HeartbeatService.ts @@ -0,0 +1,275 @@ +import * as cron from 'node-cron'; +import * as os from 'os'; +import { v4 as uuidv4 } from 'uuid'; +import JimengServer, { IJimengServer } from '@/lib/database/models/ServiceHeartbeat.ts'; +import mongoDBManager from '@/lib/database/mongodb.ts'; +import logger from '@/lib/logger.ts'; +import config from '@/lib/config.ts'; +import environment from '@/lib/environment.ts'; + +export class HeartbeatService { + private static instance: HeartbeatService; + private serverId: string; + private serverName: string; + private baseUrl: string; + private heartbeatTask: cron.ScheduledTask | null = null; + private isRunning: boolean = false; + + private constructor() { + // 优先从环境变量获取配置,否则使用默认值 + this.serverId = process.env.SERVICE_ID || config.service?.name || 'jimeng-free-api'; + this.serverName = process.env.SERVICE_NAME || this.serverId; + this.baseUrl = this.buildBaseUrl(); + } + + public static getInstance(): HeartbeatService { + if (!HeartbeatService.instance) { + HeartbeatService.instance = new HeartbeatService(); + } + return HeartbeatService.instance; + } + + private buildBaseUrl(): string { + const host = process.env.HOST || config.service?.host || '0.0.0.0'; + const port = process.env.PORT || config.service?.port || 3302; + + // 如果明确指定了 BASE_URL,直接使用 + if (process.env.BASE_URL) { + return process.env.BASE_URL; + } + + // 如果是 Docker 环境,使用容器名称 + if (process.env.NODE_ENV === 'production' && process.env.CONTAINER_NAME) { + return `http://${process.env.CONTAINER_NAME}:${port}`; + } + + // PM2 环境或Node.js直接启动环境 + let targetHost = host; + if (host === '0.0.0.0') { + // 尝试获取本机的实际IP地址 + targetHost = this.getLocalIP() || 'localhost'; + } + + return `http://${targetHost}:${port}`; + } + + private getLocalIP(): string | null { + try { + const os = require('os'); + const interfaces = os.networkInterfaces(); + + // 优先查找非回环的IPv4地址 + for (const name of Object.keys(interfaces)) { + for (const iface of interfaces[name]) { + if (iface.family === 'IPv4' && !iface.internal) { + return iface.address; + } + } + } + } catch (error) { + logger.warn('Failed to get local IP:', error.message); + } + return null; + } + + public async start(): Promise { + if (this.isRunning) { + logger.warn('Heartbeat service is already running'); + return; + } + + try { + // 确保MongoDB连接可用 + if (!mongoDBManager.isMongoConnected()) { + logger.warn('MongoDB not connected, skipping heartbeat service'); + return; + } + + // 初始化或更新服务器信息 + await this.registerServer(); + + // 立即发送第一次心跳 + await this.sendHeartbeat(); + + // 设置定时任务 - 每60秒发送一次心跳(适配 Python 项目的默认间隔) + const heartbeatInterval = process.env.HEARTBEAT_INTERVAL || config.heartbeat?.interval || 60; + const cronExpression = `*/${heartbeatInterval} * * * * *`; // 每 N 秒 + + this.heartbeatTask = cron.schedule(cronExpression, async () => { + try { + await this.sendHeartbeat(); + } catch (error) { + logger.error('Heartbeat failed:', error); + } + }, { + scheduled: false + }); + + this.heartbeatTask.start(); + this.isRunning = true; + + logger.success(`Heartbeat service started for server: ${this.serverId}`); + + // 监听进程退出事件 + process.on('SIGINT', () => this.gracefulShutdown()); + process.on('SIGTERM', () => this.gracefulShutdown()); + + } catch (error) { + logger.error('Failed to start heartbeat service:', error); + throw error; + } + } + + public async stop(): Promise { + if (!this.isRunning) { + return; + } + + if (this.heartbeatTask) { + this.heartbeatTask.stop(); + this.heartbeatTask = null; + } + + // 标记服务为非活跃状态 + await this.markInactive(); + + this.isRunning = false; + logger.info('Heartbeat service stopped'); + } + + private async registerServer(): Promise { + try { + const currentTime = Math.floor(Date.now() / 1000); + + const serverData = { + server_id: this.serverId, + server_name: this.serverName, + base_url: this.baseUrl, + is_active: true, + last_heartbeat: currentTime, + heartbeat_interval: parseInt(process.env.HEARTBEAT_INTERVAL || '60'), + updated_at: currentTime + }; + + await JimengServer.findOneAndUpdate( + { server_id: this.serverId }, + { + ...serverData, + $setOnInsert: { created_at: currentTime } + }, + { upsert: true, new: true } + ); + + logger.info(`Server registered: ${this.serverId} at ${this.baseUrl}`); + + } catch (error) { + logger.error('Failed to register server:', error); + throw error; + } + } + + private async sendHeartbeat(): Promise { + try { + const currentTime = Math.floor(Date.now() / 1000); + + await JimengServer.findOneAndUpdate( + { server_id: this.serverId }, + { + last_heartbeat: currentTime, + updated_at: currentTime, + is_active: true + } + ); + + logger.debug(`Heartbeat sent for server ${this.serverId}`); + + } catch (error) { + logger.error('Failed to send heartbeat:', error); + throw error; + } + } + + private async markInactive(): Promise { + try { + const currentTime = Math.floor(Date.now() / 1000); + + await JimengServer.findOneAndUpdate( + { server_id: this.serverId }, + { + is_active: false, + updated_at: currentTime + } + ); + + logger.info(`Server ${this.serverId} marked as inactive`); + } catch (error) { + logger.error('Failed to mark server as inactive:', error); + } + } + + private async gracefulShutdown(): Promise { + logger.info('Received shutdown signal, stopping heartbeat service...'); + await this.stop(); + process.exit(0); + } + + public getServerInfo() { + return { + serverId: this.serverId, + serverName: this.serverName, + baseUrl: this.baseUrl, + isRunning: this.isRunning + }; + } + + // 获取所有活跃服务器 + public static async getActiveServers(): Promise { + try { + return await JimengServer.find({ + is_active: true + }).sort({ last_heartbeat: -1 }); + } catch (error) { + logger.error('Failed to get active servers:', error); + return []; + } + } + + // 获取在线服务器(基于心跳超时检查) + public static async getOnlineServers(): Promise { + try { + const currentTime = Math.floor(Date.now() / 1000); + const timeoutFactor = 1.5; // 超时倍数 + + const servers = await JimengServer.find({ is_active: true }); + + return servers.filter(server => { + const heartbeatTimeout = server.heartbeat_interval * timeoutFactor; + return (currentTime - server.last_heartbeat) <= heartbeatTimeout; + }); + } catch (error) { + logger.error('Failed to get online servers:', error); + return []; + } + } + + // 清理离线服务器记录 + public static async cleanupOfflineServers(): Promise { + try { + const currentTime = Math.floor(Date.now() / 1000); + const cleanupTimeout = 24 * 60 * 60; // 24小时 + + await JimengServer.deleteMany({ + $or: [ + { is_active: false }, + { last_heartbeat: { $lt: currentTime - cleanupTimeout } } + ] + }); + + logger.debug('Cleaned up offline servers'); + } catch (error) { + logger.error('Failed to cleanup offline servers:', error); + } + } +} + +export default HeartbeatService.getInstance(); \ No newline at end of file diff --git a/src/lib/tos/TOS_README.md b/src/lib/tos/TOS_README.md new file mode 100644 index 0000000..37c2fc2 --- /dev/null +++ b/src/lib/tos/TOS_README.md @@ -0,0 +1,239 @@ +# TOS (火山对象存储) Node.js SDK 集成 + +## 📦 安装依赖 + +在您的 jimeng-free-api 项目中安装以下依赖: + +```bash +# 安装火山对象存储 JS SDK +npm install @volcengine/tos-sdk + +# 安装其他必需的依赖 +npm install axios mime-types + +# 安装 TypeScript 类型定义 +npm install --save-dev @types/node @types/mime-types typescript + +# 可选:安装开发工具 +npm install --save-dev ts-node ts-node-dev +``` + +## 📁 项目结构 + +将以下文件复制到您的 `src/lib/` 目录中: + +``` +src/lib/ +├── util.ts # 现有的工具文件 +├── tos-client.ts # TOS 客户端封装类 +├── tos-service.ts # TOS 服务类和使用示例 +└── tos-test.ts # TOS 功能测试文件(可选) +``` + +## 环境变量配置 + +在您的 `.env` 文件中添加以下配置: + +```env +# 火山对象存储配置 +TOS_ACCESS_KEY_ID=your_access_key_id +TOS_ACCESS_KEY_SECRET=your_access_key_secret +TOS_BUCKET_NAME=your_bucket_name +TOS_SELF_DOMAIN=your_custom_domain.com +``` + +## 使用方法 + +### 1. 基础配置 + +```typescript +import { TOSClientWrapper, TOSClientConfig } from './tos-client'; + +const config: TOSClientConfig = { + accessKeyId: process.env.TOS_ACCESS_KEY_ID!, + accessKeySecret: process.env.TOS_ACCESS_KEY_SECRET!, + endpoint: 'tos-cn-beijing.volces.com', + region: 'cn-beijing', + bucketName: process.env.TOS_BUCKET_NAME!, + selfDomain: process.env.TOS_SELF_DOMAIN! +}; + +const tosClient = new TOSClientWrapper(config); +``` + +### 2. 快速使用服务类 + +```typescript +import TOSService from './tos-service'; + +// 上传文本 +const textUrl = await TOSService.uploadText('Hello World', 'test.txt'); + +// 上传本地文件 +const fileUrl = await TOSService.uploadLocalFile('/path/to/file.jpg'); + +// 从URL上传 +const urlResult = await TOSService.uploadFromUrl('https://example.com/image.jpg'); + +// 上传Buffer +const buffer = Buffer.from('data'); +const bufferUrl = await TOSService.uploadBuffer(buffer, 'file.txt', 'text/plain'); +``` + +### 3. 直接使用客户端 + +```typescript +import { TOSClientWrapper } from './tos-client'; + +const client = new TOSClientWrapper(config); + +// 上传字符串 +await client.uploadString('content', 'path/file.txt'); + +// 上传文件 +await client.uploadFile('./local-file.jpg', 'uploads/image.jpg'); + +// 下载文件 +const data = await client.downloadFile('uploads/image.jpg'); + +// 删除文件 +await client.deleteFile('uploads/image.jpg'); +``` + +## API 对照表 + +以下是 Python 版本和 TypeScript 版本的 API 对照: + +| Python 方法 | TypeScript 方法 | 说明 | +|-------------|----------------|------| +| `upload_string()` | `uploadString()` | 上传字符串内容 | +| `upload_file()` | `uploadFile()` | 上传本地文件 | +| `upload_bytes()` | `uploadBytes()` | 上传字节数据 | +| `upload_from_url()` | `uploadFromUrl()` | 从URL上传文件 | +| `download_file()` | `downloadFile()` | 下载文件 | +| `delete_file()` | `deleteFile()` | 删除文件 | +| `generate_url()` | `generateUrl()` | 生成签名URL | +| `get_base_url()` | `getBaseUrl()` | 获取基础URL | + +## 特性 + +✅ 支持字符串上传 +✅ 支持本地文件上传 +✅ 支持从URL上传文件 +✅ 支持Buffer数据上传 +✅ 支持文件下载 +✅ 支持文件删除 +✅ 支持生成签名URL +✅ 支持检查文件存在性 +✅ 支持获取文件信息 +✅ 支持列出存储桶对象 +✅ 自动Content-Type检测 +✅ 错误处理和重试机制 +✅ TypeScript 类型支持 + +## 注意事项 + +1. 确保您的火山对象存储账户已正确配置 +2. 自定义域名需要在火山控制台中配置 +3. 生产环境中请妥善保管访问密钥 +4. 建议在生产环境中启用HTTPS +5. 大文件上传建议使用分片上传(可根据需要扩展) + +## 🔧 故障排除 + +### 问题 1:找不到模块 '@volcengine/tos-sdk' + +**解决方案:** +```bash +npm install @volcengine/tos-sdk +# 或 +yarn add @volcengine/tos-sdk +``` + +### 问题 2:找不到名称 'process' 或 'Buffer' + +**解决方案:** +```bash +npm install --save-dev @types/node +``` + +### 问题 3:编译错误 + +**检查 tsconfig.json 配置:** +```json +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "types": ["node"], + "esModuleInterop": true, + "moduleResolution": "node" + } +} +``` + +### 问题 4:SSL 警告 + +**如果遇到 SSL 警告,可以:** +1. 设置 `disableSSLWarnings: true` +2. 或者使用正确的 SSL 证书 + +### 问题 5:上传失败 + +**检查以下配置:** +1. AccessKey 和 SecretKey 是否正确 +2. Bucket 名称是否正确 +3. Region 是否匹配 +4. 网络连接是否正常 +5. 权限是否足够 + +### 问题 6:自定义域名访问失败 + +**检查:** +1. 域名 DNS 解析是否正确 +2. 域名是否已在火山控制台绑定 +3. HTTPS 证书是否有效 + +## 🎆 快速测试 + +**创建测试文件:** +```typescript +// test-quick.ts +import TOSService from './tos-service'; + +async function quickTest() { + try { + // 测试上传文本 + const result = await TOSService.uploadText('你好,TOS!', 'hello.txt'); + console.log('上传成功:', result); + } catch (error) { + console.error('测试失败:', error.message); + } +} + +quickTest(); +``` + +**运行测试:** +```bash +# 使用 ts-node 直接运行 +ts-node test-quick.ts + +# 或编译后运行 +tsc test-quick.ts +node test-quick.js +``` + +## 错误处理 + +所有方法都包含完善的错误处理,会抛出带有详细信息的错误: + +```typescript +try { + const result = await TOSService.uploadFile('/path/to/file.jpg'); + console.log('上传成功:', result); +} catch (error) { + console.error('上传失败:', error.message); +} +``` \ No newline at end of file diff --git a/src/lib/tos/tos-client.ts b/src/lib/tos/tos-client.ts new file mode 100644 index 0000000..434ae80 --- /dev/null +++ b/src/lib/tos/tos-client.ts @@ -0,0 +1,360 @@ +// 注意: 使用前请先安装依赖: npm install @volcengine/tos-sdk mime-types axios @types/node +import { TosClient } from '@volcengine/tos-sdk'; +import * as fs from 'fs'; +import * as path from 'path'; +import axios from 'axios'; +import { lookup as mimeLookup } from 'mime-types'; + +export interface TOSClientConfig { + accessKeyId: string; + accessKeySecret: string; + endpoint: string; + region: string; + bucketName: string; + selfDomain: string; + disableSSLWarnings?: boolean; +} + +export interface UploadOptions { + headers?: Record; + returnUrl?: boolean; + expires?: number; +} + +export class TOSClientWrapper { + private client: TosClient; + private bucketName: string; + private selfDomain: string; + private endpoint: string; + + constructor(config: TOSClientConfig) { + const { + accessKeyId, + accessKeySecret, + endpoint, + region, + bucketName, + selfDomain, + disableSSLWarnings = true + } = config; + + this.bucketName = bucketName; + this.selfDomain = selfDomain; + this.endpoint = endpoint; + + // 创建 TOS 客户端 + this.client = new TosClient({ + accessKeyId, + accessKeySecret, + endpoint: selfDomain, // 使用自定义域名作为 endpoint + region, + isCustomDomain: true, // 启用自定义域名 + connectionTimeout: 30000, + requestTimeout: 60000, + maxRetryCount: 3 + }); + + // 禁用 SSL 警告(如果需要) + if (disableSSLWarnings && process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0') { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + } + } + + /** + * 获取基础URL(不带签名参数) + */ + getBaseUrl(objectKey: string): string { + return `https://${this.selfDomain}/${objectKey}`; + } + + /** + * 生成带签名的临时访问URL + */ + async generateUrl(objectKey: string, expires: number = 3600): Promise { + try { + const result = this.client.getPreSignedUrl({ + bucket: this.bucketName, + key: objectKey, + method: 'GET', + expires + }); + return result; + } catch (error) { + throw new Error(`生成签名URL失败: ${error.message}`); + } + } + + /** + * 上传字符串内容 + */ + async uploadString( + contentStr: string, + objectKey: string, + options: UploadOptions = {} + ): Promise { + const { headers = {}, returnUrl = true } = options; + + try { + const result = await this.client.putObject({ + bucket: this.bucketName, + key: objectKey, + body: Buffer.from(contentStr, 'utf-8'), + contentType: 'text/plain', + ...headers + }); + + console.log(`upload_string http status code: ${result.statusCode}`); + + if (result.statusCode !== 200) { + throw new Error(`上传失败,HTTP状态码: ${result.statusCode}`); + } + + return returnUrl ? this.getBaseUrl(objectKey) : objectKey; + } catch (error) { + throw new Error(`上传字符串到TOS失败: ${error.message}`); + } + } + + /** + * 上传本地文件 + */ + async uploadFile( + localFilePath: string, + objectKey?: string, + options: UploadOptions = {} + ): Promise { + const { headers = {}, returnUrl = true } = options; + + if (!fs.existsSync(localFilePath)) { + throw new Error(`本地文件不存在: ${localFilePath}`); + } + + // 如果没有指定 object_key,则使用文件名 + if (!objectKey) { + objectKey = path.basename(localFilePath); + } + + // 自动设置 Content-Type + const contentType = mimeLookup(localFilePath) || 'application/octet-stream'; + + try { + const fileBuffer = fs.readFileSync(localFilePath); + + const result = await this.client.putObject({ + bucket: this.bucketName, + key: objectKey, + body: fileBuffer, + contentType, + ...headers + }); + + if (result.statusCode !== 200) { + throw new Error(`上传失败,HTTP状态码: ${result.statusCode}`); + } + + return returnUrl ? this.getBaseUrl(objectKey) : objectKey; + } catch (error) { + throw new Error(`上传文件到TOS失败: ${error.message}`); + } + } + + /** + * 上传字节数据 + */ + async uploadBytes( + data: Buffer, + objectKey: string, + contentType?: string, + options: UploadOptions = {} + ): Promise { + const { headers = {}, returnUrl = true } = options; + + try { + const result = await this.client.putObject({ + bucket: this.bucketName, + key: objectKey, + body: data, + contentType: contentType || 'application/octet-stream', + ...headers + }); + + if (result.statusCode !== 200) { + throw new Error(`上传失败,HTTP状态码: ${result.statusCode}`); + } + + return returnUrl ? this.getBaseUrl(objectKey) : objectKey; + } catch (error) { + throw new Error(`上传字节数据到TOS失败: ${error.message}`); + } + } + + /** + * 从网络URL下载文件并上传到TOS + */ + async uploadFromUrl( + url: string, + objectKey: string, + options: UploadOptions & { timeout?: number } = {} + ): Promise { + const { headers = {}, timeout = 30000, returnUrl = true } = options; + + if (!url.startsWith('http://') && !url.startsWith('https://')) { + throw new Error('URL必须以http://或https://开头'); + } + + try { + // 下载文件 + const response = await axios.get(url, { + responseType: 'arraybuffer', + timeout + }); + + // 获取内容类型 + let contentType = response.headers['content-type'] || ''; + if (!contentType) { + contentType = mimeLookup(url) || 'application/octet-stream'; + } + + // 上传到TOS + return this.uploadBytes( + Buffer.from(response.data), + objectKey, + contentType, + { headers, returnUrl } + ); + } catch (error) { + if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') { + throw new Error(`下载网络文件超时: ${url}`); + } + throw new Error(`上传网络文件到TOS失败: ${error.message}`); + } + } + + /** + * 格式化TOS对象键(路径) + */ + private formatObjectKey(objectKey: string): string { + // 如果 objectKey 包含 selfDomain,截取 selfDomain 后面的字符作为新的 objectKey + if (this.selfDomain && objectKey.includes(this.selfDomain)) { + const domainIndex = objectKey.indexOf(this.selfDomain); + if (domainIndex !== -1) { + // 截取 selfDomain 后面的部分,去掉开头的斜杠 + objectKey = objectKey.substring(domainIndex + this.selfDomain.length).replace(/^\/+/, ''); + } + } + return objectKey; + } + + /** + * 删除文件 + */ + async deleteFile(objectKey: string): Promise { + try { + const formattedKey = this.formatObjectKey(objectKey); + + await this.client.deleteObject({ + bucket: this.bucketName, + key: formattedKey + }); + + return true; + } catch (error) { + console.error(`删除文件失败: ${error.message}`); + return false; + } + } + + /** + * 从TOS下载文件并返回文件数据 + */ + async downloadFile(objectKey: string): Promise { + try { + const formattedKey = this.formatObjectKey(objectKey); + + const result = await this.client.getObjectV2({ + bucket: this.bucketName, + key: formattedKey, + dataType: 'buffer' + }); + + if (!result.data.content) { + throw new Error(`文件内容为空: ${objectKey}`); + } + + // getObjectV2 with dataType: 'buffer' 直接返回 Buffer + return result.data.content as Buffer; + + } catch (error) { + if (error.code === 'NoSuchKey') { + throw new Error(`文件不存在: ${objectKey}`); + } + throw new Error(`下载文件失败: ${error.message}`); + } + } + + /** + * 检查文件是否存在 + */ + async fileExists(objectKey: string): Promise { + try { + const formattedKey = this.formatObjectKey(objectKey); + + await this.client.headObject({ + bucket: this.bucketName, + key: formattedKey + }); + + return true; + } catch (error) { + if (error.code === 'NotFound' || error.code === 'NoSuchKey') { + return false; + } + throw new Error(`检查文件存在性失败: ${error.message}`); + } + } + + /** + * 获取文件信息 + */ + async getFileInfo(objectKey: string): Promise { + try { + const formattedKey = this.formatObjectKey(objectKey); + + const result = await this.client.headObject({ + bucket: this.bucketName, + key: formattedKey + }); + + return { + contentLength: result.data['content-length'], + contentType: result.data['content-type'], + lastModified: result.data['last-modified'], + etag: result.data.etag + }; + } catch (error) { + if (error.code === 'NotFound' || error.code === 'NoSuchKey') { + throw new Error(`文件不存在: ${objectKey}`); + } + throw new Error(`获取文件信息失败: ${error.message}`); + } + } + + /** + * 列出存储桶中的对象 + */ + async listObjects(prefix?: string, maxKeys: number = 1000): Promise { + try { + const result = await this.client.listObjects({ + bucket: this.bucketName, + prefix, + maxKeys + }); + + return result.data.Contents || []; + } catch (error) { + throw new Error(`列出对象失败: ${error.message}`); + } + } +} + +export default TOSClientWrapper; \ No newline at end of file diff --git a/src/lib/tos/tos-service.ts b/src/lib/tos/tos-service.ts new file mode 100644 index 0000000..55ee00a --- /dev/null +++ b/src/lib/tos/tos-service.ts @@ -0,0 +1,201 @@ +import { TOSClientWrapper, TOSClientConfig } from './tos-client.js'; +import * as path from 'path'; + +// TOS 客户端配置 +const TOS_CONFIG: TOSClientConfig = { + accessKeyId: process.env.TOS_ACCESS_KEY_ID || 'your-access-key-id', + accessKeySecret: process.env.TOS_ACCESS_KEY_SECRET || 'your-access-key-secret', + endpoint: process.env.TOS_ENDPOINT || 'tos-cn-beijing.volces.com', // 火山对象存储端点 + region: process.env.TOS_REGION || 'cn-beijing', + bucketName: process.env.TOS_BUCKET_NAME || 'your-bucket-name', + selfDomain: process.env.TOS_SELF_DOMAIN || 'your-domain.com', // 自定义域名 + disableSSLWarnings: true +}; + +// 创建 TOS 客户端实例 +const tosClient = new TOSClientWrapper(TOS_CONFIG); + +// 使用示例 +export class TOSService { + + /** + * 上传文本内容 + */ + static async uploadText(content: string, fileName: string): Promise { + try { + const objectKey = `text/${Date.now()}-${fileName}`; + return await tosClient.uploadString(content, objectKey); + } catch (error) { + console.error('上传文本失败:', error.message); + throw error; + } + } + + /** + * 上传本地文件 + */ + static async uploadLocalFile(filePath: string, targetPath?: string): Promise { + try { + const objectKey = targetPath || `files/${Date.now()}-${path.basename(filePath)}`; + return await tosClient.uploadFile(filePath, objectKey); + } catch (error) { + console.error('上传本地文件失败:', error.message); + throw error; + } + } + + /** + * 从URL上传文件 + */ + static async uploadFromUrl(url: string, targetPath?: string): Promise { + try { + const fileName = path.basename(new URL(url).pathname) || 'downloaded-file'; + const objectKey = targetPath || `downloads/${Date.now()}-${fileName}`; + return await tosClient.uploadFromUrl(url, objectKey); + } catch (error) { + console.error('从URL上传文件失败:', error.message); + throw error; + } + } + + /** + * 上传Buffer数据 + */ + static async uploadBuffer( + buffer: Buffer, + fileName: string, + contentType?: string, + folder: string = 'uploads' + ): Promise { + try { + const objectKey = `${folder}/${Date.now()}-${fileName}`; + return await tosClient.uploadBytes(buffer, objectKey, contentType); + } catch (error) { + console.error('上传Buffer失败:', error.message); + throw error; + } + } + + /** + * 下载文件 + */ + static async downloadFile(objectKey: string): Promise { + try { + return await tosClient.downloadFile(objectKey); + } catch (error) { + console.error('下载文件失败:', error.message); + throw error; + } + } + + /** + * 删除文件 + */ + static async deleteFile(objectKey: string): Promise { + try { + return await tosClient.deleteFile(objectKey); + } catch (error) { + console.error('删除文件失败:', error.message); + throw error; + } + } + + /** + * 检查文件是否存在 + */ + static async fileExists(objectKey: string): Promise { + try { + return await tosClient.fileExists(objectKey); + } catch (error) { + console.error('检查文件存在性失败:', error.message); + throw error; + } + } + + /** + * 获取文件信息 + */ + static async getFileInfo(objectKey: string): Promise { + try { + return await tosClient.getFileInfo(objectKey); + } catch (error) { + console.error('获取文件信息失败:', error.message); + throw error; + } + } + + /** + * 生成临时访问URL + */ + static async generateTempUrl(objectKey: string, expires: number = 3600): Promise { + try { + return await tosClient.generateUrl(objectKey, expires); + } catch (error) { + console.error('生成临时URL失败:', error.message); + throw error; + } + } + + /** + * 获取公开访问URL + */ + static getPublicUrl(objectKey: string): string { + return tosClient.getBaseUrl(objectKey); + } + + /** + * 列出文件 + */ + static async listFiles(prefix?: string, maxKeys: number = 100): Promise { + try { + return await tosClient.listObjects(prefix, maxKeys); + } catch (error) { + console.error('列出文件失败:', error.message); + throw error; + } + } +} + +// 导出客户端实例和服务类 +export { tosClient, TOSClientWrapper }; +export default TOSService; + +// 使用示例代码 +/* +// 1. 上传文本 +const textUrl = await TOSService.uploadText('Hello World', 'test.txt'); +console.log('文本上传成功:', textUrl); + +// 2. 上传本地文件 +const fileUrl = await TOSService.uploadLocalFile('/path/to/local/file.jpg'); +console.log('文件上传成功:', fileUrl); + +// 3. 从URL上传 +const urlUploadResult = await TOSService.uploadFromUrl('https://example.com/image.jpg'); +console.log('URL上传成功:', urlUploadResult); + +// 4. 上传Buffer +const buffer = Buffer.from('Hello Buffer'); +const bufferUrl = await TOSService.uploadBuffer(buffer, 'buffer-file.txt', 'text/plain'); +console.log('Buffer上传成功:', bufferUrl); + +// 5. 下载文件 +const downloadedData = await TOSService.downloadFile('uploads/file-key'); +console.log('文件下载成功, 大小:', downloadedData.length); + +// 6. 检查文件存在性 +const exists = await TOSService.fileExists('uploads/file-key'); +console.log('文件是否存在:', exists); + +// 7. 删除文件 +const deleted = await TOSService.deleteFile('uploads/file-key'); +console.log('文件删除结果:', deleted); + +// 8. 生成临时URL +const tempUrl = await TOSService.generateTempUrl('uploads/file-key', 3600); +console.log('临时URL:', tempUrl); + +// 9. 获取公开URL +const publicUrl = TOSService.getPublicUrl('uploads/file-key'); +console.log('公开URL:', publicUrl); +*/ \ No newline at end of file diff --git a/src/lib/tos/tos-test.ts b/src/lib/tos/tos-test.ts new file mode 100644 index 0000000..b1756b7 --- /dev/null +++ b/src/lib/tos/tos-test.ts @@ -0,0 +1,310 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { TOSClientWrapper, TOSClientConfig } from './tos-client.js'; +import TOSService from './tos-service.js'; + +// 测试配置 - 请根据实际情况修改 +const TEST_CONFIG: TOSClientConfig = { + accessKeyId: process.env.TOS_ACCESS_KEY_ID || 'test-access-key', + accessKeySecret: process.env.TOS_ACCESS_KEY_SECRET || 'test-secret-key', + endpoint: 'tos-cn-beijing.volces.com', + region: 'cn-beijing', + bucketName: process.env.TOS_BUCKET_NAME || 'test-bucket', + selfDomain: process.env.TOS_SELF_DOMAIN || 'test-domain.com' +}; + +class TOSClientTest { + private tosClient: TOSClientWrapper; + private testResults: { [key: string]: boolean } = {}; + + constructor() { + this.tosClient = new TOSClientWrapper(TEST_CONFIG); + } + + /** + * 创建测试文件 + */ + private createTestFile(): string { + const testDir = path.join(process.cwd(), 'test-files'); + if (!fs.existsSync(testDir)) { + fs.mkdirSync(testDir, { recursive: true }); + } + + const testFilePath = path.join(testDir, 'test-image.txt'); + const testContent = `测试文件内容\n创建时间: ${new Date().toISOString()}\n这是一个用于测试TOS上传功能的文件。`; + + fs.writeFileSync(testFilePath, testContent, 'utf-8'); + return testFilePath; + } + + /** + * 测试字符串上传 + */ + async testUploadString(): Promise { + console.log('\n🧪 测试字符串上传...'); + try { + const testContent = `测试字符串内容 - ${Date.now()}`; + const objectKey = `test/string-${Date.now()}.txt`; + + const url = await this.tosClient.uploadString(testContent, objectKey); + console.log('✅ 字符串上传成功:', url); + + // 验证文件是否存在 + const exists = await this.tosClient.fileExists(objectKey); + console.log('✅ 文件存在性验证:', exists); + + this.testResults['uploadString'] = true; + return objectKey; + } catch (error: any) { + console.error('❌ 字符串上传失败:', error.message); + this.testResults['uploadString'] = false; + return null; + } + } + + /** + * 测试本地文件上传 + */ + async testUploadFile(): Promise { + console.log('\n🧪 测试本地文件上传...'); + try { + const testFilePath = this.createTestFile(); + const objectKey = `test/file-${Date.now()}.txt`; + + const url = await this.tosClient.uploadFile(testFilePath, objectKey); + console.log('✅ 本地文件上传成功:', url); + + // 清理测试文件 + fs.unlinkSync(testFilePath); + fs.rmdirSync(path.dirname(testFilePath)); + + this.testResults['uploadFile'] = true; + return objectKey; + } catch (error: any) { + console.error('❌ 本地文件上传失败:', error.message); + this.testResults['uploadFile'] = false; + return null; + } + } + + /** + * 测试Buffer上传 + */ + async testUploadBuffer(): Promise { + console.log('\n🧪 测试Buffer上传...'); + try { + const testBuffer = Buffer.from(`测试Buffer内容 - ${Date.now()}`, 'utf-8'); + const objectKey = `test/buffer-${Date.now()}.txt`; + + const url = await this.tosClient.uploadBytes(testBuffer, objectKey, 'text/plain'); + console.log('✅ Buffer上传成功:', url); + + this.testResults['uploadBuffer'] = true; + return objectKey; + } catch (error: any) { + console.error('❌ Buffer上传失败:', error.message); + this.testResults['uploadBuffer'] = false; + return null; + } + } + + /** + * 测试文件下载 + */ + async testDownloadFile(objectKey: string): Promise { + if (!objectKey) { + console.log('\n⏭️ 跳过文件下载测试 (没有可用的测试文件)'); + return; + } + + console.log('\n🧪 测试文件下载...'); + try { + const data = await this.tosClient.downloadFile(objectKey); + console.log('✅ 文件下载成功, 大小:', data.length, '字节'); + console.log('📄 文件内容预览:', data.toString('utf-8').substring(0, 100) + '...'); + + this.testResults['downloadFile'] = true; + } catch (error: any) { + console.error('❌ 文件下载失败:', error.message); + this.testResults['downloadFile'] = false; + } + } + + /** + * 测试文件信息获取 + */ + async testGetFileInfo(objectKey: string): Promise { + if (!objectKey) { + console.log('\n⏭️ 跳过文件信息测试 (没有可用的测试文件)'); + return; + } + + console.log('\n🧪 测试获取文件信息...'); + try { + const info = await this.tosClient.getFileInfo(objectKey); + console.log('✅ 文件信息获取成功:', info); + + this.testResults['getFileInfo'] = true; + } catch (error: any) { + console.error('❌ 获取文件信息失败:', error.message); + this.testResults['getFileInfo'] = false; + } + } + + /** + * 测试生成签名URL + */ + async testGenerateUrl(objectKey: string): Promise { + if (!objectKey) { + console.log('\n⏭️ 跳过URL生成测试 (没有可用的测试文件)'); + return; + } + + console.log('\n🧪 测试生成签名URL...'); + try { + const signedUrl = await this.tosClient.generateUrl(objectKey, 3600); + console.log('✅ 签名URL生成成功:', signedUrl.substring(0, 100) + '...'); + + const publicUrl = this.tosClient.getBaseUrl(objectKey); + console.log('✅ 公开URL生成成功:', publicUrl); + + this.testResults['generateUrl'] = true; + } catch (error: any) { + console.error('❌ URL生成失败:', error.message); + this.testResults['generateUrl'] = false; + } + } + + /** + * 测试服务类方法 + */ + async testTOSService(): Promise { + console.log('\n🧪 测试TOS服务类...'); + try { + // 测试上传文本 + const textUrl = await TOSService.uploadText('服务类测试内容', 'service-test.txt'); + console.log('✅ 服务类文本上传成功:', textUrl); + + // 测试上传Buffer + const buffer = Buffer.from('服务类Buffer测试', 'utf-8'); + const bufferUrl = await TOSService.uploadBuffer(buffer, 'service-buffer-test.txt', 'text/plain'); + console.log('✅ 服务类Buffer上传成功:', bufferUrl); + + this.testResults['tosService'] = true; + } catch (error: any) { + console.error('❌ TOS服务类测试失败:', error.message); + this.testResults['tosService'] = false; + } + } + + /** + * 测试文件删除(清理测试文件) + */ + async testDeleteFile(objectKey: string): Promise { + if (!objectKey) { + console.log('\n⏭️ 跳过文件删除测试 (没有可用的测试文件)'); + return; + } + + console.log('\n🧪 测试文件删除...'); + try { + const deleted = await this.tosClient.deleteFile(objectKey); + console.log('✅ 文件删除成功:', deleted); + + // 验证文件是否已被删除 + const exists = await this.tosClient.fileExists(objectKey); + console.log('✅ 删除验证 (应为false):', exists); + + this.testResults['deleteFile'] = true; + } catch (error: any) { + console.error('❌ 文件删除失败:', error.message); + this.testResults['deleteFile'] = false; + } + } + + /** + * 运行所有测试 + */ + async runAllTests(): Promise { + console.log('🚀 开始TOS客户端功能测试...'); + console.log('📋 测试配置:'); + console.log(' - Endpoint:', TEST_CONFIG.endpoint); + console.log(' - Region:', TEST_CONFIG.region); + console.log(' - Bucket:', TEST_CONFIG.bucketName); + console.log(' - Domain:', TEST_CONFIG.selfDomain); + + let testObjectKey: string | null = null; + + // 执行各项测试 + testObjectKey = await this.testUploadString(); + await this.testUploadFile(); + const bufferObjectKey = await this.testUploadBuffer(); + + // 使用上传的文件进行其他测试 + if (testObjectKey) { + await this.testDownloadFile(testObjectKey); + await this.testGetFileInfo(testObjectKey); + await this.testGenerateUrl(testObjectKey); + } + + // 测试服务类 + await this.testTOSService(); + + // 清理测试文件 + if (testObjectKey) { + await this.testDeleteFile(testObjectKey); + } + if (bufferObjectKey) { + await this.testDeleteFile(bufferObjectKey); + } + + // 输出测试结果 + this.printTestResults(); + } + + /** + * 打印测试结果 + */ + private printTestResults(): void { + console.log('\n📊 测试结果汇总:'); + console.log('=' .repeat(50)); + + let passCount = 0; + let totalCount = 0; + + Object.entries(this.testResults).forEach(([testName, result]) => { + totalCount++; + if (result) { + passCount++; + console.log(`✅ ${testName}: 通过`); + } else { + console.log(`❌ ${testName}: 失败`); + } + }); + + console.log('=' .repeat(50)); + console.log(`📈 总体结果: ${passCount}/${totalCount} 测试通过`); + + if (passCount === totalCount) { + console.log('🎉 所有测试都通过了!TOS客户端集成成功!'); + } else { + console.log('⚠️ 部分测试失败,请检查配置和网络连接。'); + } + } +} + +// 运行测试的函数 +export async function runTOSTests(): Promise { + const tester = new TOSClientTest(); + await tester.runAllTests(); +} + +// 如果直接运行此文件,则执行测试 +// if (require.main === module) { +// runTOSTests().catch(error => { +// console.error('测试执行失败:', error); +// process.exit(1); +// }); +// } + +export default TOSClientTest; \ No newline at end of file diff --git a/start-node.sh b/start-node.sh new file mode 100644 index 0000000..11888df --- /dev/null +++ b/start-node.sh @@ -0,0 +1,371 @@ +#!/bin/bash + +# 即梦 Free API Node.js 启动脚本 +# 使用方法: ./start-node.sh [dev|prod|stop|restart|status] + +set -e + +# 颜色输出 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 日志函数 +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 检查Node.js环境 +check_node_env() { + if ! command -v node &> /dev/null; then + log_error "Node.js 未安装,请先安装 Node.js v20.x" + exit 1 + fi + + if ! command -v yarn &> /dev/null; then + log_error "Yarn 未安装,请先安装 Yarn" + exit 1 + fi + + NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1) + if [ "$NODE_VERSION" -lt 18 ]; then + log_warning "建议使用 Node.js v20.x,当前版本: $(node -v)" + fi + + log_success "Node.js 环境检查通过: $(node -v)" +} + +# 检查PM2 +check_pm2() { + if ! command -v pm2 &> /dev/null; then + log_info "PM2 未安装,正在安装..." + npm install -g pm2 + log_success "PM2 安装完成" + else + log_success "PM2 已安装: $(pm2 -v)" + fi +} + +# 构建项目 +build_project() { + log_info "构建项目..." + yarn install + yarn run build + log_success "项目构建完成" +} + +# 加载环境变量文件 +load_env_file() { + if [ -f ".env" ]; then + log_info "加载 .env 文件..." + export $(grep -v '^#' .env | xargs) + log_success "环境变量已从 .env 文件加载" + elif [ -f ".env.template" ]; then + log_warning "发现 .env.template 文件,请复制为 .env 并配置实际值" + log_info "命令: cp .env.template .env" + fi +} + +# 验证必要的环境变量 +validate_env() { + local missing_vars=() + + # 检查必须的环境变量 + [ -z "$MONGODB_URL" ] && missing_vars+=("MONGODB_URL") + [ -z "$TOS_ACCESS_KEY_ID" ] && missing_vars+=("TOS_ACCESS_KEY_ID") + [ -z "$TOS_ACCESS_KEY_SECRET" ] && missing_vars+=("TOS_ACCESS_KEY_SECRET") + [ -z "$TOS_BUCKET_NAME" ] && missing_vars+=("TOS_BUCKET_NAME") + + if [ ${#missing_vars[@]} -gt 0 ]; then + log_error "缺少必要的环境变量:" + for var in "${missing_vars[@]}"; do + log_error " - $var" + done + log_info "请设置这些环境变量或创建 .env 文件" + log_info "参考: .env.template" + return 1 + fi + + log_success "环境变量验证通过" + return 0 +} + +# 设置环境变量 (示例) +setup_env() { + local env_type=$1 + + log_info "设置环境变量..." + + # 先尝试加载 .env 文件 + load_env_file + + # 基础环境变量 (如果未设置则使用默认值) + export NODE_ENV=${NODE_ENV:-${env_type}} + export SERVICE_ID=${SERVICE_ID:-"jimeng-api-${env_type}"} + export SERVICE_NAME=${SERVICE_NAME:-"jimeng-free-api-${env_type}"} + export HOST=${HOST:-"0.0.0.0"} + export PORT=${PORT:-"3302"} + export TOS_REGION=${TOS_REGION:-"cn-beijing"} + export TOS_ENDPOINT=${TOS_ENDPOINT:-"tos-cn-beijing.volces.com"} + export HEARTBEAT_ENABLED=${HEARTBEAT_ENABLED:-"true"} + export HEARTBEAT_INTERVAL=${HEARTBEAT_INTERVAL:-"30"} + + # 验证必要的环境变量 + if ! validate_env; then + exit 1 + fi + + log_success "环境变量设置完成" + log_info "服务ID: $SERVICE_ID" + log_info "MongoDB: ${MONGODB_URL%%\?*}" # 只显示主机部分,隐藏查询参数 + log_info "TOS Region: $TOS_REGION" +} + +# 直接启动 (开发模式) +start_dev() { + log_info "启动开发模式..." + setup_env "development" + + if [ ! -d "dist" ]; then + build_project + fi + + node dist/index.js +} + +# PM2启动 (生产模式) +start_pm2() { + local env_type=$1 + + log_info "使用PM2启动 (${env_type}模式)..." + check_pm2 + + if [ ! -d "dist" ]; then + build_project + fi + + # 停止现有进程 + pm2 delete jimeng-free-api 2>/dev/null || true + + # 启动新进程 + if [ "$env_type" = "production" ]; then + pm2 start ecosystem.config.json --env production + else + pm2 start ecosystem.config.json + fi + + pm2 save + log_success "PM2启动完成" +} + +# 停止服务 +stop_service() { + log_info "停止服务..." + + # 停止PM2进程 + if command -v pm2 &> /dev/null; then + pm2 delete jimeng-free-api 2>/dev/null || true + log_success "PM2进程已停止" + fi + + # 停止Node.js进程 + pkill -f "node dist/index.js" 2>/dev/null || true + log_success "Node.js进程已停止" +} + +# 重启服务 +restart_service() { + log_info "重启服务..." + stop_service + sleep 2 + start_pm2 "production" +} + +# 查看状态 +check_status() { + log_info "检查服务状态..." + + echo "=== PM2 进程状态 ===" + if command -v pm2 &> /dev/null; then + pm2 list + else + echo "PM2 未安装" + fi + + echo + echo "=== Node.js 进程状态 ===" + ps aux | grep "node dist/index.js" | grep -v grep || echo "未找到 Node.js 进程" + + echo + echo "=== 端口状态 ===" + netstat -tlnp 2>/dev/null | grep :3302 || echo "端口 3302 未被占用" + + echo + echo "=== 服务健康检查 ===" + if curl -s http://localhost:3302/ping > /dev/null 2>&1; then + log_success "API 服务正常运行" + + # 检查服务器信息 + echo "=== 当前服务器信息 ===" + curl -s http://localhost:3302/api/servers/current | jq '.data' 2>/dev/null || curl -s http://localhost:3302/api/servers/current + else + log_error "API 服务无法访问" + fi +} + +# 查看日志 +view_logs() { + log_info "查看日志..." + + if command -v pm2 &> /dev/null && pm2 list | grep -q jimeng-free-api; then + pm2 logs jimeng-free-api --lines 50 + else + log_warning "PM2未运行,查看本地日志文件..." + if [ -f "./logs/combined.log" ]; then + tail -n 50 ./logs/combined.log + else + log_warning "未找到日志文件" + fi + fi +} + +# 显示帮助 +show_help() { + echo "即梦 Free API Node.js 启动脚本" + echo + echo "使用方法:" + echo " $0 " + echo + echo "命令:" + echo " dev 直接启动开发模式 (Node.js)" + echo " prod 使用PM2启动生产模式" + echo " pm2 使用PM2启动开发模式" + echo " stop 停止所有服务" + echo " restart 重启PM2服务" + echo " status 查看服务状态" + echo " logs 查看服务日志" + echo " build 仅构建项目" + echo " help 显示此帮助信息" + echo + echo "多实例管理:" + echo " ./multi-instance.sh start all production # 启动所有实例 (端口 3302, 3303, 3304)" + echo " ./multi-instance.sh start 3302,3303 # 启动指定端口实例" + echo " ./multi-instance.sh stop all # 停止所有实例" + echo " ./multi-instance.sh status # 查看所有实例状态" + echo " ./multi-instance.sh logs 3302 # 查看指定实例日志" + echo + echo "环境变量配置方式:" + echo " 方式1: 使用 .env 文件 (推荐)" + echo " cp .env.template .env" + echo " # 编辑 .env 文件,填入实际配置" + echo " $0 prod" + echo + echo " 方式2: 直接设置环境变量" + echo " export MONGODB_URL='mongodb://localhost:27017/jimeng-api'" + echo " export TOS_ACCESS_KEY_ID='your_key_id'" + echo " # ... 其他变量" + echo " $0 prod" + echo + echo " 方式3: PM2 ecosystem 配置" + echo " # 编辑 ecosystem.config.json 中的 env 或 env_production 节点" + echo " pm2 start ecosystem.config.json --env production" + echo + echo "必须设置的环境变量:" + echo " MONGODB_URL MongoDB连接地址" + echo " TOS_ACCESS_KEY_ID TOS访问密钥ID" + echo " TOS_ACCESS_KEY_SECRET TOS访问密钥" + echo " TOS_BUCKET_NAME TOS存储桶名称" + echo + echo "可选环境变量 (有默认值):" + echo " NODE_ENV 运行环境 (development/production)" + echo " SERVICE_ID 服务器唯一标识" + echo " SERVICE_NAME 服务器名称" + echo " HOST 绑定主机 (默认: 0.0.0.0)" + echo " PORT 端口号 (默认: 3302)" + echo " BASE_URL 服务器基础URL (自动检测)" + echo " TOS_REGION TOS地区 (默认: cn-beijing)" + echo " TOS_ENDPOINT TOS端点 (默认: tos-cn-beijing.volces.com)" + echo " TOS_SELF_DOMAIN TOS自定义域名" + echo " HEARTBEAT_ENABLED 启用心跳 (默认: true)" + echo " HEARTBEAT_INTERVAL 心跳间隔秒数 (默认: 30)" + echo + echo "示例:" + echo " # 快速开始 (使用 .env 文件)" + echo " cp .env.template .env" + echo " # 编辑 .env 文件后:" + echo " $0 prod" + echo + echo " # 临时环境变量启动" + echo " MONGODB_URL='mongodb://localhost:27017/jimeng-api' \\" + echo " TOS_ACCESS_KEY_ID='your_key' \\" + echo " TOS_ACCESS_KEY_SECRET='your_secret' \\" + echo " TOS_BUCKET_NAME='your_bucket' \\" + echo " $0 dev" +} + +# 主函数 +main() { + local command="$1" + + case "$command" in + dev) + check_node_env + start_dev + ;; + prod) + check_node_env + start_pm2 "production" + ;; + pm2) + check_node_env + start_pm2 "development" + ;; + stop) + stop_service + ;; + restart) + check_node_env + restart_service + ;; + status) + check_status + ;; + logs) + view_logs + ;; + build) + check_node_env + build_project + ;; + help|--help|-h) + show_help + ;; + "") + log_error "请指定命令" + show_help + exit 1 + ;; + *) + log_error "未知命令: $command" + show_help + exit 1 + ;; + esac +} + +# 执行主函数 +main "$@" \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index b6477c3..318fe7f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,9 @@ }, "outDir": "./dist" }, - "include": ["src/**/*", "libs.d.ts"], + "include": [ + "src/**/*", + "libs.d.ts" + ], "exclude": ["node_modules", "dist"] } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index a8cf435..a6bbc5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -56,6 +56,13 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@mongodb-js/saslprep@^1.1.9": + version "1.3.0" + resolved "https://registry.npmmirror.com/@mongodb-js/saslprep/-/saslprep-1.3.0.tgz" + integrity sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ== + dependencies: + sparse-bitfield "^3.0.3" + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" @@ -99,6 +106,13 @@ dependencies: "@types/node" "*" +"@types/http-proxy@^1.17.8": + version "1.17.16" + resolved "https://registry.npmmirror.com/@types/http-proxy/-/http-proxy-1.17.16.tgz" + integrity sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w== + dependencies: + "@types/node" "*" + "@types/lodash@^4.14.202": version "4.17.7" resolved "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.7.tgz" @@ -114,6 +128,11 @@ resolved "https://registry.npmmirror.com/@types/mime/-/mime-3.0.4.tgz" integrity sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw== +"@types/node-cron@^3.0.11": + version "3.0.11" + resolved "https://registry.npmmirror.com/@types/node-cron/-/node-cron-3.0.11.tgz" + integrity sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg== + "@types/node@*": version "20.14.12" resolved "https://registry.npmmirror.com/@types/node/-/node-20.14.12.tgz" @@ -121,6 +140,33 @@ dependencies: undici-types "~5.26.4" +"@types/webidl-conversions@*": + version "7.0.3" + resolved "https://registry.npmmirror.com/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz" + integrity sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA== + +"@types/whatwg-url@^11.0.2": + version "11.0.5" + resolved "https://registry.npmmirror.com/@types/whatwg-url/-/whatwg-url-11.0.5.tgz" + integrity sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ== + dependencies: + "@types/webidl-conversions" "*" + +"@volcengine/tos-sdk@^2.6.7": + version "2.7.5" + resolved "https://registry.npmmirror.com/@volcengine/tos-sdk/-/tos-sdk-2.7.5.tgz" + integrity sha512-3qUCXegPq8GqSk4F7aHeQ0HImDttNqwnc1lGv3NRd9V0vuBTqL0450bCXeLAcMOKhfZTSuB/Bf4JbT05zLEIJA== + dependencies: + axios "^0.21.1" + axios-adapter-uniapp "^0.1.4" + crypto-js "^4.2.0" + debug "^4.3.4" + http-proxy-middleware "^2.0.1" + lodash "^4.17.21" + qs "^6.11.2" + tos-crc64-js "0.0.1" + type-fest "^1.4.0" + accepts@^1.3.5: version "1.3.8" resolved "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz" @@ -179,6 +225,28 @@ asynckit@^0.4.0: resolved "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== +axios-adapter-uniapp@^0.1.4: + version "0.1.4" + resolved "https://registry.npmmirror.com/axios-adapter-uniapp/-/axios-adapter-uniapp-0.1.4.tgz" + integrity sha512-4je5JcWGrrTjPEJXVXJZnOkv+BsnYn/fKbQmjxzdCGFyoQw1gq3tfQ4/WhLzy+Gi9cQJl3K8EH26G7U0BK3wcw== + dependencies: + axios "^0.27.2" + +axios@^0.21.1: + version "0.21.4" + resolved "https://registry.npmmirror.com/axios/-/axios-0.21.4.tgz" + integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg== + dependencies: + follow-redirects "^1.14.0" + +axios@^0.27.2: + version "0.27.2" + resolved "https://registry.npmmirror.com/axios/-/axios-0.27.2.tgz" + integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ== + dependencies: + follow-redirects "^1.14.9" + form-data "^4.0.0" + axios@^1.6.7: version "1.7.2" resolved "https://registry.npmmirror.com/axios/-/axios-1.7.2.tgz" @@ -212,6 +280,11 @@ braces@^3.0.3, braces@~3.0.2: dependencies: fill-range "^7.1.1" +bson@^6.10.4: + version "6.10.4" + resolved "https://registry.npmmirror.com/bson/-/bson-6.10.4.tgz" + integrity sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng== + bundle-require@^5.0.0: version "5.0.0" resolved "https://registry.npmmirror.com/bundle-require/-/bundle-require-5.0.0.tgz" @@ -370,6 +443,11 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" +crypto-js@^4.2.0: + version "4.2.0" + resolved "https://registry.npmmirror.com/crypto-js/-/crypto-js-4.2.0.tgz" + integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== + date-fns-tz@^3.2.0: version "3.2.0" resolved "https://registry.npmmirror.com/date-fns-tz/-/date-fns-tz-3.2.0.tgz" @@ -380,7 +458,7 @@ date-fns-tz@^3.2.0: resolved "https://registry.npmmirror.com/date-fns/-/date-fns-3.6.0.tgz" integrity sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww== -debug@^4.3.2, debug@^4.3.4, debug@^4.3.5: +debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@4.x: version "4.3.6" resolved "https://registry.npmmirror.com/debug/-/debug-4.3.6.tgz" integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== @@ -513,6 +591,11 @@ escape-html@^1.0.3: resolved "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz" integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== +eventemitter3@^4.0.0: + version "4.0.7" + resolved "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-4.0.7.tgz" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + eventsource-parser@^1.1.2: version "1.1.2" resolved "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-1.1.2.tgz" @@ -558,7 +641,7 @@ fill-range@^7.1.1: dependencies: to-regex-range "^5.0.1" -follow-redirects@^1.15.6: +follow-redirects@^1.0.0, follow-redirects@^1.14.0, follow-redirects@^1.14.9, follow-redirects@^1.15.6: version "1.15.6" resolved "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.6.tgz" integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== @@ -745,6 +828,26 @@ http-errors@2.0.0: statuses "2.0.1" toidentifier "1.0.1" +http-proxy-middleware@^2.0.1: + version "2.0.9" + resolved "https://registry.npmmirror.com/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz" + integrity sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q== + dependencies: + "@types/http-proxy" "^1.17.8" + http-proxy "^1.18.1" + is-glob "^4.0.1" + is-plain-obj "^3.0.0" + micromatch "^4.0.2" + +http-proxy@^1.18.1: + version "1.18.1" + resolved "https://registry.npmmirror.com/http-proxy/-/http-proxy-1.18.1.tgz" + integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== + dependencies: + eventemitter3 "^4.0.0" + follow-redirects "^1.0.0" + requires-port "^1.0.0" + human-signals@^2.1.0: version "2.1.0" resolved "https://registry.npmmirror.com/human-signals/-/human-signals-2.1.0.tgz" @@ -813,6 +916,11 @@ is-number@^7.0.0: resolved "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== +is-plain-obj@^3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-3.0.0.tgz" + integrity sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA== + is-stream@^2.0.0: version "2.0.1" resolved "https://registry.npmmirror.com/is-stream/-/is-stream-2.0.1.tgz" @@ -846,6 +954,11 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +kareem@2.6.3: + version "2.6.3" + resolved "https://registry.npmmirror.com/kareem/-/kareem-2.6.3.tgz" + integrity sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q== + keygrip@~1.1.0: version "1.1.0" resolved "https://registry.npmmirror.com/keygrip/-/keygrip-1.1.0.tgz" @@ -976,6 +1089,11 @@ media-typer@0.3.0: resolved "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz" integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== +memory-pager@^1.0.2: + version "1.5.0" + resolved "https://registry.npmmirror.com/memory-pager/-/memory-pager-1.5.0.tgz" + integrity sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg== + merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.npmmirror.com/merge-stream/-/merge-stream-2.0.0.tgz" @@ -991,7 +1109,7 @@ methods@^1.1.2: resolved "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz" integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== -micromatch@^4.0.4: +micromatch@^4.0.2, micromatch@^4.0.4: version "4.0.7" resolved "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.7.tgz" integrity sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q== @@ -1004,7 +1122,7 @@ mime-db@1.52.0: resolved "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@^2.1.12, mime-types@^2.1.18, mime-types@~2.1.24, mime-types@~2.1.34: +mime-types@^2.1.12, mime-types@^2.1.18, mime-types@^2.1.35, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== @@ -1038,11 +1156,58 @@ minimist@^1.2.8: resolved "https://registry.npmmirror.com/minipass/-/minipass-7.1.2.tgz" integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== +mongodb-connection-string-url@^3.0.0: + version "3.0.2" + resolved "https://registry.npmmirror.com/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz" + integrity sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA== + dependencies: + "@types/whatwg-url" "^11.0.2" + whatwg-url "^14.1.0 || ^13.0.0" + +mongodb@~6.18.0: + version "6.18.0" + resolved "https://registry.npmmirror.com/mongodb/-/mongodb-6.18.0.tgz" + integrity sha512-fO5ttN9VC8P0F5fqtQmclAkgXZxbIkYRTUi1j8JO6IYwvamkhtYDilJr35jOPELR49zqCJgXZWwCtW7B+TM8vQ== + dependencies: + "@mongodb-js/saslprep" "^1.1.9" + bson "^6.10.4" + mongodb-connection-string-url "^3.0.0" + +mongoose@^8.0.3: + version "8.18.0" + resolved "https://registry.npmmirror.com/mongoose/-/mongoose-8.18.0.tgz" + integrity sha512-3TixPihQKBdyaYDeJqRjzgb86KbilEH07JmzV8SoSjgoskNTpa6oTBmDxeoF9p8YnWQoz7shnCyPkSV/48y3yw== + dependencies: + bson "^6.10.4" + kareem "2.6.3" + mongodb "~6.18.0" + mpath "0.9.0" + mquery "5.0.0" + ms "2.1.3" + sift "17.1.3" + +mpath@0.9.0: + version "0.9.0" + resolved "https://registry.npmmirror.com/mpath/-/mpath-0.9.0.tgz" + integrity sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew== + +mquery@5.0.0: + version "5.0.0" + resolved "https://registry.npmmirror.com/mquery/-/mquery-5.0.0.tgz" + integrity sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg== + dependencies: + debug "4.x" + ms@2.1.2: version "2.1.2" resolved "https://registry.npmmirror.com/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@2.1.3: + version "2.1.3" + resolved "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + mz@^2.7.0: version "2.7.0" resolved "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz" @@ -1057,6 +1222,13 @@ negotiator@0.6.3: resolved "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== +node-cron@^3.0.3: + version "3.0.3" + resolved "https://registry.npmmirror.com/node-cron/-/node-cron-3.0.3.tgz" + integrity sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A== + dependencies: + uuid "8.3.2" + normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz" @@ -1165,12 +1337,12 @@ proxy-from-env@^1.1.0: resolved "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz" integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== -punycode@^2.1.0: +punycode@^2.1.0, punycode@^2.3.1: version "2.3.1" resolved "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -qs@^6.11.0, qs@^6.4.0, qs@^6.5.2: +qs@^6.11.0, qs@^6.11.2, qs@^6.4.0, qs@^6.5.2: version "6.12.3" resolved "https://registry.npmmirror.com/qs/-/qs-6.12.3.tgz" integrity sha512-AWJm14H1vVaO/iNZ4/hO+HyaTehuy9nRqVdkTqlJt0HWvBiBIEXFmb4C0DGeYo3Xes9rrEW+TxHsaigCbN5ICQ== @@ -1211,6 +1383,11 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.npmmirror.com/requires-port/-/requires-port-1.0.0.tgz" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + resolve-from@^5.0.0: version "5.0.0" resolved "https://registry.npmmirror.com/resolve-from/-/resolve-from-5.0.0.tgz" @@ -1302,6 +1479,11 @@ side-channel@^1.0.6: get-intrinsic "^1.2.4" object-inspect "^1.13.1" +sift@17.1.3: + version "17.1.3" + resolved "https://registry.npmmirror.com/sift/-/sift-17.1.3.tgz" + integrity sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ== + signal-exit@^3.0.3: version "3.0.7" resolved "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz" @@ -1324,6 +1506,13 @@ source-map@0.8.0-beta.0: dependencies: whatwg-url "^7.0.0" +sparse-bitfield@^3.0.3: + version "3.0.3" + resolved "https://registry.npmmirror.com/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz" + integrity sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ== + dependencies: + memory-pager "^1.0.2" + statuses@^1.5.0, "statuses@>= 1.5.0 < 2": version "1.5.0" resolved "https://registry.npmmirror.com/statuses/-/statuses-1.5.0.tgz" @@ -1431,6 +1620,11 @@ toidentifier@1.0.1: resolved "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +tos-crc64-js@0.0.1: + version "0.0.1" + resolved "https://registry.npmmirror.com/tos-crc64-js/-/tos-crc64-js-0.0.1.tgz" + integrity sha512-l2Ndi9BaDH3gmAJ5VIS/1WeqFcucK41WCbw5dije24k2XtUCclYfvhntWIVHe+0S85QcED4vjqm3AO1izlQ30g== + tr46@^1.0.1: version "1.0.1" resolved "https://registry.npmmirror.com/tr46/-/tr46-1.0.1.tgz" @@ -1438,6 +1632,13 @@ tr46@^1.0.1: dependencies: punycode "^2.1.0" +tr46@^5.1.0: + version "5.1.1" + resolved "https://registry.npmmirror.com/tr46/-/tr46-5.1.1.tgz" + integrity sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw== + dependencies: + punycode "^2.3.1" + tree-kill@^1.2.2: version "1.2.2" resolved "https://registry.npmmirror.com/tree-kill/-/tree-kill-1.2.2.tgz" @@ -1475,6 +1676,11 @@ tsup@^8.0.2: sucrase "^3.35.0" tree-kill "^1.2.2" +type-fest@^1.4.0: + version "1.4.0" + resolved "https://registry.npmmirror.com/type-fest/-/type-fest-1.4.0.tgz" + integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== + type-is@^1.6.14, type-is@^1.6.16, type-is@^1.6.18: version "1.6.18" resolved "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz" @@ -1508,6 +1714,11 @@ uuid@^9.0.1: resolved "https://registry.npmmirror.com/uuid/-/uuid-9.0.1.tgz" integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== +uuid@8.3.2: + version "8.3.2" + resolved "https://registry.npmmirror.com/uuid/-/uuid-8.3.2.tgz" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + vary@^1.1.2: version "1.1.2" resolved "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz" @@ -1518,6 +1729,19 @@ webidl-conversions@^4.0.2: resolved "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz" integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg== +webidl-conversions@^7.0.0: + version "7.0.0" + resolved "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz" + integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== + +"whatwg-url@^14.1.0 || ^13.0.0": + version "14.2.0" + resolved "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-14.2.0.tgz" + integrity sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw== + dependencies: + tr46 "^5.1.0" + webidl-conversions "^7.0.0" + whatwg-url@^7.0.0: version "7.1.0" resolved "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-7.1.0.tgz"