Initial commit of akmon project
This commit is contained in:
326
push-receiver-service/DEPLOYMENT.md
Normal file
326
push-receiver-service/DEPLOYMENT.md
Normal file
@@ -0,0 +1,326 @@
|
||||
# 🚀 推送消息接收服务部署指南
|
||||
|
||||
这个独立的推送消息接收服务专门用于接收和存储各种推送消息到 Supabase 数据库。
|
||||
|
||||
## 📋 服务概述
|
||||
|
||||
- **目的**: 专门接收推送信息的独立服务
|
||||
- **数据库**: Supabase PostgreSQL
|
||||
- **端口**: 3001 (可配置)
|
||||
- **功能**: 接收、存储、去重、统计、监控推送消息
|
||||
|
||||
## 🚀 快速部署
|
||||
|
||||
### 1. 准备 Supabase 项目
|
||||
|
||||
1. **创建 Supabase 项目**:
|
||||
```
|
||||
访问: https://supabase.com/dashboard
|
||||
点击: "New project"
|
||||
记录: Project URL 和 API Keys
|
||||
```
|
||||
|
||||
2. **执行数据库脚本**:
|
||||
- 打开 Supabase Dashboard > SQL Editor
|
||||
- 复制并执行 `push-receiver-service/database/supabase-init.sql`
|
||||
|
||||
### 2. 本地部署
|
||||
|
||||
```bash
|
||||
# 进入服务目录
|
||||
cd push-receiver-service
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 配置环境
|
||||
cp .env.supabase .env
|
||||
# 编辑 .env 文件,配置您的 Supabase 信息
|
||||
|
||||
# 初始化数据库
|
||||
npm run setup-supabase
|
||||
|
||||
# 启动服务
|
||||
npm start
|
||||
```
|
||||
|
||||
### 3. 使用便捷脚本
|
||||
|
||||
**Windows:**
|
||||
```cmd
|
||||
cd push-receiver-service
|
||||
start.bat
|
||||
```
|
||||
|
||||
**Linux/Mac:**
|
||||
```bash
|
||||
cd push-receiver-service
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
**或者从主项目目录:**
|
||||
```bash
|
||||
npm run push-receiver:setup # 初始化
|
||||
npm run push-receiver # 启动服务
|
||||
npm run push-receiver:test # 运行测试
|
||||
```
|
||||
|
||||
## 🌐 暴露的服务地址
|
||||
|
||||
启动成功后,推送接收服务将暴露以下地址:
|
||||
|
||||
### 本地开发
|
||||
```
|
||||
服务信息: http://localhost:3001/
|
||||
健康检查: http://localhost:3001/api/health
|
||||
推送消息: http://localhost:3001/api/push/message
|
||||
批量推送: http://localhost:3001/api/push/batch
|
||||
```
|
||||
|
||||
### 生产环境部署
|
||||
|
||||
1. **云服务器部署**:
|
||||
```
|
||||
https://your-domain.com:3001/api/push/message
|
||||
```
|
||||
|
||||
2. **反向代理 (推荐)**:
|
||||
```
|
||||
https://your-domain.com/api/push/message
|
||||
https://push.your-domain.com/api/push/message
|
||||
```
|
||||
|
||||
3. **Serverless 部署**:
|
||||
```
|
||||
https://your-function-url/api/push/message
|
||||
```
|
||||
|
||||
## 📊 数据库设计
|
||||
|
||||
### 核心表结构
|
||||
|
||||
```sql
|
||||
-- 推送消息主表
|
||||
push_messages (
|
||||
id, -- 消息唯一ID
|
||||
push_type, -- 推送类型 (SOS, HEALTH, LOCATION, etc.)
|
||||
user_id, -- 用户ID
|
||||
device_id, -- 设备ID
|
||||
raw_data, -- 原始数据 (JSONB)
|
||||
parsed_data, -- 解析数据 (JSONB)
|
||||
received_at, -- 接收时间
|
||||
processing_status, -- 处理状态
|
||||
is_duplicate, -- 是否重复
|
||||
source_ip, -- 来源IP
|
||||
latitude, longitude -- 地理位置
|
||||
)
|
||||
|
||||
-- 其他支持表
|
||||
push_types -- 推送类型配置
|
||||
message_processing_logs -- 处理日志
|
||||
devices -- 设备信息
|
||||
users -- 用户信息
|
||||
system_stats -- 系统统计
|
||||
```
|
||||
|
||||
### 支持的推送类型
|
||||
|
||||
| 类型 | 说明 | 优先级 | 示例数据 |
|
||||
|------|------|--------|----------|
|
||||
| `SOS` | 紧急求救 | 1 | `{emergencyLevel: "HIGH"}` |
|
||||
| `HEALTH` | 健康数据 | 3 | `{H: 75, O: 98, T: 36.5}` |
|
||||
| `LOCATION` | 位置信息 | 4 | `{lat: 39.9042, lng: 116.4074}` |
|
||||
| `ALERT` | 告警信息 | 2 | `{alertType: "fall", severity: "high"}` |
|
||||
| `DEVICE_STATUS` | 设备状态 | 4 | `{status: "online", battery: 85}` |
|
||||
| `ACTIVITY` | 活动数据 | 5 | `{type: "running", duration: 1800}` |
|
||||
|
||||
## 🔧 推送设备配置
|
||||
|
||||
### API 调用格式
|
||||
|
||||
```http
|
||||
POST http://localhost:3001/api/push/message
|
||||
Content-Type: application/json
|
||||
X-API-Key: your-api-key
|
||||
|
||||
{
|
||||
"pushType": "HEALTH",
|
||||
"userId": "user_12345",
|
||||
"deviceId": "device_001",
|
||||
"H": 75,
|
||||
"O": 98,
|
||||
"T": 36.5
|
||||
}
|
||||
```
|
||||
|
||||
### 各种语言调用示例
|
||||
|
||||
**JavaScript:**
|
||||
```javascript
|
||||
fetch('http://localhost:3001/api/push/message', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': 'your-api-key'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pushType: 'HEALTH',
|
||||
userId: 'user_12345',
|
||||
H: 105, O: 88
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
import requests
|
||||
|
||||
requests.post('http://localhost:3001/api/push/message',
|
||||
headers={'X-API-Key': 'your-api-key'},
|
||||
json={'pushType': 'HEALTH', 'userId': 'user_12345', 'H': 105, 'O': 88}
|
||||
)
|
||||
```
|
||||
|
||||
**cURL:**
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/push/message \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-d '{"pushType":"HEALTH","userId":"user_12345","H":105,"O":88}'
|
||||
```
|
||||
|
||||
## 🔒 安全配置
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
```env
|
||||
# Supabase 配置
|
||||
SUPABASE_URL=https://your-project.supabase.co
|
||||
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
||||
|
||||
# 安全配置
|
||||
API_KEY=your-secure-api-key-2025
|
||||
ALLOWED_ORIGINS=https://your-app.com,https://your-admin.com
|
||||
RATE_LIMIT_MAX_REQUESTS=1000
|
||||
|
||||
# 服务配置
|
||||
PORT=3001
|
||||
LOG_LEVEL=info
|
||||
```
|
||||
|
||||
### 生产环境安全建议
|
||||
|
||||
1. **使用强 API 密钥**
|
||||
2. **配置 CORS 白名单**
|
||||
3. **启用请求频率限制**
|
||||
4. **使用 HTTPS**
|
||||
5. **定期备份数据**
|
||||
|
||||
## 📈 监控和维护
|
||||
|
||||
### 健康检查
|
||||
|
||||
```bash
|
||||
curl http://localhost:3001/api/health
|
||||
```
|
||||
|
||||
### 统计信息
|
||||
|
||||
```bash
|
||||
curl http://localhost:3001/api/stats
|
||||
```
|
||||
|
||||
### 日志监控
|
||||
|
||||
```bash
|
||||
# 查看日志
|
||||
tail -f push-receiver-service/logs/supabase-push-service.log
|
||||
|
||||
# 查看错误日志
|
||||
tail -f push-receiver-service/logs/supabase-push-service-error.log
|
||||
```
|
||||
|
||||
### 数据清理
|
||||
|
||||
```bash
|
||||
# 清理30天前的数据
|
||||
curl -X POST http://localhost:3001/api/cleanup \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-d '{"days": 30}'
|
||||
```
|
||||
|
||||
## 🚀 部署到生产环境
|
||||
|
||||
### 1. 云服务器部署
|
||||
|
||||
```bash
|
||||
# 使用 PM2 管理进程
|
||||
npm install -g pm2
|
||||
pm2 start supabase-server.js --name "push-receiver"
|
||||
pm2 startup
|
||||
pm2 save
|
||||
```
|
||||
|
||||
### 2. Docker 部署
|
||||
|
||||
```dockerfile
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN npm install --production
|
||||
EXPOSE 3001
|
||||
CMD ["node", "supabase-server.js"]
|
||||
```
|
||||
|
||||
### 3. Nginx 反向代理
|
||||
|
||||
```nginx
|
||||
location /api/push/ {
|
||||
proxy_pass http://localhost:3001/api/push/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
```
|
||||
|
||||
## 📞 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **无法连接 Supabase**:
|
||||
- 检查 SUPABASE_URL 和 SUPABASE_SERVICE_ROLE_KEY
|
||||
- 确认网络连接正常
|
||||
|
||||
2. **API 调用失败**:
|
||||
- 检查 API_KEY 是否正确
|
||||
- 确认请求格式正确
|
||||
|
||||
3. **服务无法启动**:
|
||||
- 检查端口 3001 是否被占用
|
||||
- 查看错误日志获取详细信息
|
||||
|
||||
### 调试步骤
|
||||
|
||||
1. 运行测试脚本: `npm test`
|
||||
2. 检查日志文件
|
||||
3. 验证环境配置
|
||||
4. 测试 Supabase 连接
|
||||
|
||||
---
|
||||
|
||||
## 📋 总结
|
||||
|
||||
这个推送消息接收服务提供了:
|
||||
|
||||
✅ **完整的推送消息接收和存储**
|
||||
✅ **基于 Supabase 的可扩展数据库**
|
||||
✅ **详细的日志和监控功能**
|
||||
✅ **安全的 API 访问控制**
|
||||
✅ **支持多种推送消息类型**
|
||||
✅ **重复消息检测和处理**
|
||||
✅ **批量消息处理能力**
|
||||
✅ **生产级的部署支持**
|
||||
|
||||
推送设备或系统只需要向 `http://your-server:3001/api/push/message` 发送 POST 请求即可完成消息推送。所有消息都会被完整保存到 Supabase 数据库中,供后续业务处理使用。
|
||||
118
push-receiver-service/PS_PREFIX_UPDATE_SUMMARY.md
Normal file
118
push-receiver-service/PS_PREFIX_UPDATE_SUMMARY.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# 推送消息接收服务表名前缀更新完成
|
||||
|
||||
## 🎉 更新完成
|
||||
|
||||
所有推送消息接收服务相关的数据库表已成功添加 `ps_` 前缀,以避免与现有系统表的命名冲突。
|
||||
|
||||
## 📋 表名变更对照
|
||||
|
||||
| 原表名 | 新表名 | 用途 |
|
||||
|--------|--------|------|
|
||||
| `push_messages` | `ps_push_messages` | 推送消息主表 |
|
||||
| `push_types` | `ps_push_types` | 推送类型配置表 |
|
||||
| `message_processing_logs` | `ps_message_processing_logs` | 消息处理日志表 |
|
||||
| `system_stats` | `ps_system_stats` | 系统统计表 |
|
||||
|
||||
## 🔧 更新内容
|
||||
|
||||
### 1. 数据库结构文件
|
||||
- ✅ `database/supabase-init.sql` - 所有表定义、索引、触发器、视图、函数都已更新
|
||||
|
||||
### 2. 应用代码
|
||||
- ✅ `lib/supabase-database.js` - 所有数据库操作方法中的表名引用已更新
|
||||
- ✅ `check-config.js` - 配置检查脚本中的表名引用已更新
|
||||
|
||||
### 3. 文档
|
||||
- ✅ `SUPABASE_ADAPTATION.md` - 适配说明文档已更新
|
||||
- ✅ `README.md` - 使用说明已更新
|
||||
|
||||
### 4. 视图和函数
|
||||
- ✅ `ps_recent_message_stats` - 消息统计视图
|
||||
- ✅ `ps_active_devices_stats` - 设备活跃度统计视图
|
||||
- ✅ `ps_cleanup_old_messages()` - 清理旧消息函数
|
||||
- ✅ `ps_get_message_stats()` - 获取消息统计函数
|
||||
|
||||
### 5. 索引和约束
|
||||
- ✅ 所有索引名称已更新为带 `ps_` 前缀
|
||||
- ✅ 外键约束已正确关联到 `ak_users` 和 `ak_devices` 表
|
||||
- ✅ 触发器名称已更新
|
||||
|
||||
## 🚀 部署步骤
|
||||
|
||||
### 1. 数据库初始化
|
||||
```bash
|
||||
# 在 Supabase SQL Editor 中执行
|
||||
# 文件: database/supabase-init.sql
|
||||
```
|
||||
|
||||
### 2. 环境配置
|
||||
```bash
|
||||
# 确保 .env 文件中有正确的 Supabase 配置
|
||||
SUPABASE_URL=your-supabase-url
|
||||
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
||||
API_KEY=your-api-key
|
||||
```
|
||||
|
||||
### 3. 测试服务
|
||||
```bash
|
||||
# 检查配置
|
||||
npm run check-config
|
||||
|
||||
# 运行测试
|
||||
npm test
|
||||
|
||||
# 启动服务
|
||||
npm start
|
||||
```
|
||||
|
||||
## 📊 API 使用无变化
|
||||
|
||||
API 接口和使用方式保持不变,只是底层数据库表名发生了变化:
|
||||
|
||||
```javascript
|
||||
// API 调用方式不变
|
||||
const response = await fetch('/api/push/message', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': 'your-api-key'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'HEALTH',
|
||||
data: {
|
||||
user_id: '123e4567-e89b-12d3-a456-426614174000',
|
||||
device_id: '987fcdeb-51a2-43d7-8f9e-123456789abc',
|
||||
H: 72,
|
||||
O: 98,
|
||||
T: 36.5
|
||||
}
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
## 🛡️ 数据安全
|
||||
|
||||
- ✅ 所有现有数据保持不变
|
||||
- ✅ 与现有 `ak_*` 表系统完全兼容
|
||||
- ✅ 外键约束确保数据完整性
|
||||
- ✅ RLS(行级安全)策略已正确配置
|
||||
|
||||
## 🔍 监控和维护
|
||||
|
||||
推送消息服务现在使用独立的 `ps_` 前缀表,可以通过以下方式监控:
|
||||
|
||||
- 查看 `ps_push_messages` 表了解消息接收情况
|
||||
- 查看 `ps_message_processing_logs` 表了解处理日志
|
||||
- 查看 `ps_system_stats` 表了解系统统计
|
||||
- 使用 `ps_recent_message_stats` 视图获取实时统计
|
||||
|
||||
## ✅ 完成状态
|
||||
|
||||
- [x] 数据库表结构更新
|
||||
- [x] 应用代码适配
|
||||
- [x] 文档更新
|
||||
- [x] 配置检查工具更新
|
||||
- [x] 视图和函数更新
|
||||
- [x] 索引和约束更新
|
||||
|
||||
服务现在已准备就绪,可以在您的 Supabase 环境中部署和使用!
|
||||
405
push-receiver-service/README.md
Normal file
405
push-receiver-service/README.md
Normal file
@@ -0,0 +1,405 @@
|
||||
# 基于 Supabase 的推送消息接收服务
|
||||
|
||||
专门用于接收和存储各种推送消息到 Supabase 数据库的独立服务。
|
||||
|
||||
## 🌟 特性
|
||||
|
||||
- ✅ **基于 Supabase**: 使用 Supabase PostgreSQL 数据库
|
||||
- ✅ **推送消息接收**: 支持多种类型的推送消息(SOS、健康、位置、告警等)
|
||||
- ✅ **批量处理**: 支持单个和批量消息处理
|
||||
- ✅ **重复检测**: 自动检测和处理重复消息
|
||||
- ✅ **实时监控**: 提供详细的统计和监控功能
|
||||
- ✅ **安全保护**: API 密钥验证和请求频率限制
|
||||
- ✅ **完整日志**: 详细的操作日志记录
|
||||
- ✅ **优雅关闭**: 支持服务的优雅启动和关闭
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
cd push-receiver-service
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. 配置 Supabase
|
||||
|
||||
1. **创建 Supabase 项目**:
|
||||
- 访问 [https://supabase.com/dashboard](https://supabase.com/dashboard)
|
||||
- 点击 "New project" 创建新项目
|
||||
- 记录项目的 URL 和 API Keys
|
||||
|
||||
2. **⚠️ 重要:数据库适配说明**:
|
||||
- 本服务已适配现有的 `ak_users` 和 `ak_devices` 表
|
||||
- 推送消息相关表使用 `ps_` 前缀(`ps_push_messages`、`ps_push_types` 等)
|
||||
- 请确保现有的基础表已存在于您的 Supabase 项目中
|
||||
- 详细适配信息请查看 [`SUPABASE_ADAPTATION.md`](./SUPABASE_ADAPTATION.md)
|
||||
|
||||
3. **执行数据库初始化脚本**:
|
||||
- 在 Supabase Dashboard 中打开 "SQL Editor"
|
||||
- 复制 `database/supabase-init.sql` 文件内容
|
||||
- 粘贴到 SQL Editor 中并执行
|
||||
|
||||
4. **配置环境变量**:
|
||||
```bash
|
||||
cp .env.supabase .env
|
||||
```
|
||||
|
||||
然后编辑 `.env` 文件:
|
||||
```env
|
||||
SUPABASE_URL=https://your-project-ref.supabase.co
|
||||
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
||||
API_KEY=your-custom-api-key
|
||||
```
|
||||
|
||||
### 3. 初始化数据库
|
||||
|
||||
```bash
|
||||
node setup-supabase.js
|
||||
```
|
||||
|
||||
### 4. 启动服务
|
||||
|
||||
```bash
|
||||
node supabase-server.js
|
||||
```
|
||||
|
||||
服务将在 `http://localhost:3001` 启动。
|
||||
|
||||
## 📋 API 端点
|
||||
|
||||
### 基础信息
|
||||
|
||||
- **服务信息**: `GET /`
|
||||
- **健康检查**: `GET /api/health`
|
||||
|
||||
### 推送消息接收
|
||||
|
||||
- **单个消息**: `POST /api/push/message`
|
||||
- **批量消息**: `POST /api/push/batch`
|
||||
|
||||
### 数据查询
|
||||
|
||||
- **统计信息**: `GET /api/stats`
|
||||
- **消息列表**: `GET /api/messages`
|
||||
- **清理数据**: `POST /api/cleanup`
|
||||
|
||||
## 🔧 API 使用示例
|
||||
|
||||
### 发送单个推送消息
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/push/message \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-d '{
|
||||
"pushType": "HEALTH",
|
||||
"userId": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"deviceId": "987fcdeb-51a2-43d7-8f9e-123456789abc",
|
||||
"H": 75,
|
||||
"O": 98,
|
||||
"T": 36.5
|
||||
}'
|
||||
```
|
||||
|
||||
> **注意**: `userId` 和 `deviceId` 必须使用 UUID 格式,且必须是现有 `ak_users` 和 `ak_devices` 表中的有效记录。
|
||||
|
||||
### 发送批量推送消息
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/push/batch \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-d '{
|
||||
"messages": [
|
||||
{
|
||||
"pushType": "SOS",
|
||||
"userId": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"emergencyLevel": "HIGH",
|
||||
"lat": 39.9042,
|
||||
"lng": 116.4074
|
||||
},
|
||||
{
|
||||
"pushType": "HEALTH",
|
||||
"userId": "456e7890-e89b-12d3-a456-426614174001",
|
||||
"H": 80,
|
||||
"O": 95
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### JavaScript 调用示例
|
||||
|
||||
```javascript
|
||||
// 发送单个推送消息
|
||||
const response = await fetch('http://localhost:3001/api/push/message', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': 'your-api-key'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pushType: 'HEALTH',
|
||||
userId: '123e4567-e89b-12d3-a456-426614174000',
|
||||
deviceId: '987fcdeb-51a2-43d7-8f9e-123456789abc',
|
||||
H: 105,
|
||||
O: 88
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
console.log('推送结果:', result);
|
||||
```
|
||||
|
||||
### Python 调用示例
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
# 发送单个推送消息
|
||||
response = requests.post('http://localhost:3001/api/push/message',
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': 'your-api-key'
|
||||
},
|
||||
json={
|
||||
'pushType': 'HEALTH',
|
||||
'userId': 'user_12345',
|
||||
'H': 105,
|
||||
'O': 88
|
||||
}
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
print('推送结果:', result)
|
||||
```
|
||||
|
||||
## 📊 支持的推送类型
|
||||
|
||||
| 类型 | 说明 | 优先级 | 必需字段 |
|
||||
|------|------|--------|----------|
|
||||
| `SOS` | 紧急求救 | 1 (最高) | `userId` |
|
||||
| `ALERT` | 告警信息 | 2 | `userId`, `alertType` |
|
||||
| `HEALTH` | 健康数据 | 3 | `userId` |
|
||||
| `LOCATION` | 位置信息 | 4 | `userId` |
|
||||
| `DEVICE_STATUS` | 设备状态 | 4 | `deviceId` |
|
||||
| `ACTIVITY` | 活动数据 | 5 | `userId` |
|
||||
|
||||
## 🗄️ 数据库结构
|
||||
|
||||
### 主要数据表
|
||||
|
||||
1. **push_messages**: 推送消息主表
|
||||
2. **push_types**: 推送类型配置表
|
||||
3. **message_processing_logs**: 消息处理日志表
|
||||
4. **devices**: 设备信息表
|
||||
5. **users**: 用户信息表
|
||||
6. **system_stats**: 系统统计表
|
||||
|
||||
### 消息数据结构
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"push_type": "HEALTH",
|
||||
"user_id": "user_12345",
|
||||
"device_id": "device_001",
|
||||
"raw_data": { "完整的原始数据" },
|
||||
"parsed_data": { "解析后的结构化数据" },
|
||||
"received_at": "2025-06-25T10:30:00Z",
|
||||
"processing_status": "processed",
|
||||
"priority": 3,
|
||||
"is_duplicate": false,
|
||||
"source_ip": "192.168.1.100",
|
||||
"user_agent": "Device Client/1.0"
|
||||
}
|
||||
```
|
||||
|
||||
## 🔒 安全配置
|
||||
|
||||
### API 密钥验证
|
||||
|
||||
在环境变量中设置 `API_KEY`:
|
||||
|
||||
```env
|
||||
API_KEY=your-secure-api-key
|
||||
```
|
||||
|
||||
所有 API 请求(除健康检查外)都需要在请求头中包含:
|
||||
|
||||
```
|
||||
X-API-Key: your-secure-api-key
|
||||
```
|
||||
|
||||
### 请求频率限制
|
||||
|
||||
- 默认:每分钟最多 1000 个请求
|
||||
- 可通过环境变量配置:`RATE_LIMIT_MAX_REQUESTS`
|
||||
|
||||
### CORS 配置
|
||||
|
||||
可通过 `ALLOWED_ORIGINS` 环境变量配置允许的来源。
|
||||
|
||||
## 📈 监控和日志
|
||||
|
||||
### 日志文件
|
||||
|
||||
- `logs/supabase-push-service.log`: 一般日志
|
||||
- `logs/supabase-push-service-error.log`: 错误日志
|
||||
- `logs/database.log`: 数据库操作日志
|
||||
|
||||
### 统计信息
|
||||
|
||||
访问 `/api/stats` 获取详细的统计信息:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"service": {
|
||||
"uptime": 3600,
|
||||
"requestCount": 150,
|
||||
"messageCount": 120,
|
||||
"errorCount": 2
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"push_type": "HEALTH",
|
||||
"total_count": 80,
|
||||
"processed_count": 78,
|
||||
"failed_count": 2,
|
||||
"pending_count": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 环境配置
|
||||
|
||||
### 完整的环境变量配置
|
||||
|
||||
```env
|
||||
# 服务器配置
|
||||
PORT=3001
|
||||
HOST=0.0.0.0
|
||||
NODE_ENV=production
|
||||
|
||||
# Supabase 配置
|
||||
SUPABASE_URL=https://your-project-ref.supabase.co
|
||||
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
||||
|
||||
# 安全配置
|
||||
API_KEY=your-secure-api-key
|
||||
ALLOWED_ORIGINS=*
|
||||
RATE_LIMIT_MAX_REQUESTS=1000
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL=info
|
||||
LOG_DIR=./logs
|
||||
|
||||
# 消息处理配置
|
||||
MAX_MESSAGE_SIZE=1048576
|
||||
BATCH_SIZE_LIMIT=1000
|
||||
ENABLE_DUPLICATE_CHECK=true
|
||||
|
||||
# 数据清理配置
|
||||
AUTO_CLEANUP_ENABLED=true
|
||||
AUTO_CLEANUP_DAYS=30
|
||||
```
|
||||
|
||||
## 🚀 部署
|
||||
|
||||
### Docker 部署
|
||||
|
||||
```dockerfile
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --production
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "supabase-server.js"]
|
||||
```
|
||||
|
||||
### PM2 部署
|
||||
|
||||
```bash
|
||||
npm install -g pm2
|
||||
pm2 start supabase-server.js --name "supabase-push-receiver"
|
||||
pm2 startup
|
||||
pm2 save
|
||||
```
|
||||
|
||||
### 云服务部署
|
||||
|
||||
支持部署到:
|
||||
- Vercel
|
||||
- Netlify
|
||||
- Railway
|
||||
- Heroku
|
||||
- AWS Lambda
|
||||
- 腾讯云函数
|
||||
|
||||
## 🛠️ 开发
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
push-receiver-service/
|
||||
├── lib/
|
||||
│ └── supabase-database.js # Supabase 数据库操作类
|
||||
├── database/
|
||||
│ └── supabase-init.sql # 数据库初始化脚本
|
||||
├── logs/ # 日志目录
|
||||
├── supabase-server.js # 主服务器文件
|
||||
├── setup-supabase.js # 数据库设置脚本
|
||||
├── package.json
|
||||
├── .env.supabase # Supabase 环境配置模板
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
# 测试 Supabase 连接
|
||||
node setup-supabase.js
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 🤝 业务集成
|
||||
|
||||
### 与现有系统集成
|
||||
|
||||
1. **配置推送地址**: 将设备或系统的推送地址指向此服务
|
||||
2. **设置API密钥**: 确保所有推送请求都包含正确的API密钥
|
||||
3. **监控日志**: 定期检查服务日志确保正常运行
|
||||
4. **数据分析**: 使用 Supabase Dashboard 或 API 进行数据分析
|
||||
|
||||
### 数据流处理
|
||||
|
||||
```
|
||||
设备/系统 → 推送消息 → 接收服务 → Supabase → 业务处理
|
||||
↓
|
||||
实时通知/告警
|
||||
```
|
||||
|
||||
## 📞 支持
|
||||
|
||||
如果您在使用过程中遇到问题:
|
||||
|
||||
1. 检查日志文件获取详细错误信息
|
||||
2. 确认 Supabase 配置正确
|
||||
3. 验证 API 密钥和网络连接
|
||||
4. 查看 Supabase Dashboard 的数据库状态
|
||||
|
||||
---
|
||||
|
||||
**注意**: 这是一个专门的消息接收服务,只负责接收和存储推送消息。业务逻辑处理需要另外实现。
|
||||
167
push-receiver-service/SUPABASE_ADAPTATION.md
Normal file
167
push-receiver-service/SUPABASE_ADAPTATION.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Supabase 数据库适配说明
|
||||
|
||||
## 概述
|
||||
|
||||
推送消息接收服务的数据库结构已适配现有的 `ak_users` 和 `ak_devices` 表结构,以避免重复创建表和数据冲突。所有推送消息相关的表都使用 `ps_` 前缀命名。
|
||||
|
||||
## 主要变更
|
||||
|
||||
### 1. 移除独立表
|
||||
- ❌ 移除独立的 `devices` 表
|
||||
- ❌ 移除独立的 `users` 表
|
||||
- ✅ 使用现有的 `public.ak_devices` 表
|
||||
- ✅ 使用现有的 `public.ak_users` 表
|
||||
|
||||
### 2. 表命名规范
|
||||
- ✅ `ps_push_messages` - 推送消息主表
|
||||
- ✅ `ps_push_types` - 推送类型配置表
|
||||
- ✅ `ps_message_processing_logs` - 消息处理日志表
|
||||
- ✅ `ps_system_stats` - 系统统计表
|
||||
|
||||
### 3. 修改关联字段
|
||||
- `ps_push_messages.user_id`: VARCHAR(255) → UUID,关联到 `public.ak_users(id)`
|
||||
- `ps_push_messages.device_id`: VARCHAR(255) → UUID,关联到 `public.ak_devices(id)`
|
||||
|
||||
#### `ps_push_messages` 表结构
|
||||
```sql
|
||||
CREATE TABLE ps_push_messages (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
message_id VARCHAR(255),
|
||||
push_type VARCHAR(50) NOT NULL,
|
||||
user_id UUID REFERENCES public.ak_users(id) ON DELETE SET NULL,
|
||||
device_id UUID REFERENCES public.ak_devices(id) ON DELETE SET NULL,
|
||||
raw_data JSONB NOT NULL,
|
||||
parsed_data JSONB,
|
||||
received_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
processing_status VARCHAR(20) DEFAULT 'pending',
|
||||
-- 其他字段...
|
||||
);
|
||||
```
|
||||
|
||||
### 4. 适配表结构
|
||||
|
||||
#### `public.ak_users` 表结构
|
||||
```sql
|
||||
CREATE TABLE public.ak_users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(64) UNIQUE NOT NULL,
|
||||
email VARCHAR(128) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(256) NOT NULL,
|
||||
gender VARCHAR(16) DEFAULT 'other',
|
||||
birthday DATE,
|
||||
height_cm INT,
|
||||
weight_kg INT,
|
||||
bio TEXT,
|
||||
phone VARCHAR(32),
|
||||
avatar_url TEXT,
|
||||
region_id UUID REFERENCES public.ak_regions(id),
|
||||
school_id UUID REFERENCES public.ak_schools(id),
|
||||
grade_id UUID REFERENCES public.ak_grades(id),
|
||||
class_id UUID REFERENCES public.ak_classes(id),
|
||||
role VARCHAR(32) DEFAULT 'student',
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
auth_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
preferred_language UUID REFERENCES public.ak_languages(id)
|
||||
);
|
||||
```
|
||||
|
||||
#### `public.ak_devices` 表结构
|
||||
```sql
|
||||
CREATE TABLE public.ak_devices (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
device_type VARCHAR(32) NOT NULL,
|
||||
device_name VARCHAR(64),
|
||||
device_mac VARCHAR(64),
|
||||
bind_time TIMESTAMPTZ DEFAULT now(),
|
||||
status VARCHAR(16) DEFAULT 'active',
|
||||
extra JSONB
|
||||
);
|
||||
```
|
||||
|
||||
### 5. 数据库操作调整
|
||||
|
||||
#### 设备信息处理
|
||||
```javascript
|
||||
// 旧方式:自动创建/更新设备
|
||||
await this.upsertDevice(deviceData);
|
||||
|
||||
// 新方式:检查并更新现有设备状态
|
||||
await this.upsertDevice({
|
||||
device_id: deviceData.device_id, // 必须是现有设备的 UUID
|
||||
metadata: deviceData.metadata
|
||||
});
|
||||
```
|
||||
|
||||
#### 用户信息处理
|
||||
```javascript
|
||||
// 旧方式:自动创建/更新用户
|
||||
await this.upsertUser(userData);
|
||||
|
||||
// 新方式:检查现有用户(不自动创建)
|
||||
await this.upsertUser({
|
||||
user_id: userData.user_id // 必须是现有用户的 UUID
|
||||
});
|
||||
```
|
||||
|
||||
## API 调用变更
|
||||
|
||||
### 推送消息接口
|
||||
```javascript
|
||||
// 发送推送消息时,user_id 和 device_id 必须使用 UUID 格式
|
||||
const response = await fetch('/api/push/message', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': 'your-api-key'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'HEALTH',
|
||||
data: {
|
||||
user_id: '123e4567-e89b-12d3-a456-426614174000', // UUID 格式
|
||||
device_id: '987fcdeb-51a2-43d7-8f9e-123456789abc', // UUID 格式
|
||||
H: 72, // 心率
|
||||
O: 98, // 血氧
|
||||
T: 36.5 // 体温
|
||||
}
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
如果推送消息中包含不存在的 user_id 或 device_id:
|
||||
- 消息仍会被存储到 `push_messages` 表
|
||||
- 会在日志中记录警告信息
|
||||
- 不会自动创建用户或设备记录
|
||||
|
||||
## 部署注意事项
|
||||
|
||||
### 1. 数据库初始化
|
||||
确保在执行 `database/supabase-init.sql` 之前,现有的 `ak_users` 和 `ak_devices` 表已经存在。
|
||||
|
||||
### 2. 外键约束
|
||||
推送消息表与现有表建立了外键关联:
|
||||
- `ps_push_messages.user_id` → `public.ak_users(id)`
|
||||
- `ps_push_messages.device_id` → `public.ak_devices(id)`
|
||||
|
||||
### 3. 权限设置
|
||||
确保 Supabase Service Role 对现有表有适当的访问权限。
|
||||
|
||||
## 数据完整性
|
||||
|
||||
### 推送消息存储
|
||||
- ✅ 所有推送消息都会完整存储
|
||||
- ✅ 支持 user_id 或 device_id 为 NULL 的情况
|
||||
- ✅ 通过外键约束保证数据一致性
|
||||
|
||||
### 统计和查询
|
||||
- ✅ 可以通过关联查询获取用户和设备的详细信息
|
||||
- ✅ 统计功能完全兼容现有表结构
|
||||
|
||||
## 使用建议
|
||||
|
||||
1. **用户和设备管理**:通过专门的用户管理和设备管理接口创建和维护用户、设备信息
|
||||
2. **推送消息**:只负责接收和存储推送消息,不处理用户/设备的创建
|
||||
3. **数据关联**:通过外键关联获取完整的用户和设备信息
|
||||
4. **监控告警**:监控日志中关于不存在用户/设备的警告信息
|
||||
117
push-receiver-service/check-config.js
Normal file
117
push-receiver-service/check-config.js
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Supabase 连接配置检查脚本
|
||||
* 帮助验证 Supabase 连接配置是否正确
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
|
||||
class SupabaseConfigChecker {
|
||||
constructor() {
|
||||
this.config = {
|
||||
SUPABASE_URL: process.env.SUPABASE_URL,
|
||||
SUPABASE_ANON_KEY: process.env.SUPABASE_ANON_KEY,
|
||||
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
||||
API_KEY: process.env.API_KEY
|
||||
};
|
||||
}
|
||||
|
||||
async checkConfig() {
|
||||
console.log('🔍 检查 Supabase 配置...\n');
|
||||
|
||||
// 检查环境变量
|
||||
console.log('📋 环境变量检查:');
|
||||
for (const [key, value] of Object.entries(this.config)) {
|
||||
if (value) {
|
||||
const displayValue = value.length > 20 ?
|
||||
value.substring(0, 20) + '...' : value;
|
||||
console.log(`✅ ${key}: ${displayValue}`);
|
||||
} else {
|
||||
console.log(`❌ ${key}: 未设置`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n🌐 URL 格式检查:');
|
||||
if (this.config.SUPABASE_URL) {
|
||||
try {
|
||||
const url = new URL(this.config.SUPABASE_URL);
|
||||
console.log(`✅ 协议: ${url.protocol}`);
|
||||
console.log(`✅ 主机: ${url.hostname}`);
|
||||
console.log(`✅ 端口: ${url.port || '默认'}`);
|
||||
} catch (error) {
|
||||
console.log(`❌ URL 格式错误: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试基本连接测试
|
||||
if (this.config.SUPABASE_URL && this.config.SUPABASE_ANON_KEY) {
|
||||
console.log('\n🔗 尝试连接测试...');
|
||||
await this.testConnection();
|
||||
} else {
|
||||
console.log('\n⚠️ 无法进行连接测试,缺少必要配置');
|
||||
}
|
||||
|
||||
console.log('\n💡 配置建议:');
|
||||
this.provideSuggestions();
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
try {
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
|
||||
const supabase = createClient(
|
||||
this.config.SUPABASE_URL,
|
||||
this.config.SUPABASE_ANON_KEY
|
||||
);
|
||||
// 尝试一个简单的查询
|
||||
const { data, error } = await supabase
|
||||
.from('ps_push_messages')
|
||||
.select('count')
|
||||
.limit(1);
|
||||
|
||||
if (error) {
|
||||
console.log(`❌ 连接测试失败: ${error.message}`);
|
||||
|
||||
// 提供错误分析
|
||||
if (error.message.includes('authentication')) {
|
||||
console.log('💡 建议: 检查 SUPABASE_ANON_KEY 是否正确');
|
||||
} if (error.message.includes('relation') && error.message.includes('does not exist')) {
|
||||
console.log('💡 建议: 数据库中缺少 ps_push_messages 表,请先执行数据库初始化脚本');
|
||||
}
|
||||
if (error.message.includes('connection')) {
|
||||
console.log('💡 建议: 检查 SUPABASE_URL 是否可访问');
|
||||
}
|
||||
} else {
|
||||
console.log('✅ 基本连接测试成功');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log(`❌ 连接测试出错: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
provideSuggestions() {
|
||||
console.log('1. 确保 Supabase 实例正在运行并可访问');
|
||||
console.log('2. 检查防火墙和网络配置');
|
||||
console.log('3. 验证 API 密钥的有效性');
|
||||
console.log('4. 确保数据库中存在必要的表结构');
|
||||
console.log('5. 如使用自托管 Supabase,确认服务状态');
|
||||
|
||||
console.log('\n📝 下一步操作:');
|
||||
console.log('1. 更新 .env 文件中的正确配置');
|
||||
console.log('2. 运行: npm run setup-supabase');
|
||||
console.log('3. 运行: npm test');
|
||||
console.log('4. 运行: npm start');
|
||||
}
|
||||
}
|
||||
|
||||
// 执行检查
|
||||
async function main() {
|
||||
const checker = new SupabaseConfigChecker();
|
||||
await checker.checkConfig();
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch(console.error);
|
||||
}
|
||||
|
||||
module.exports = SupabaseConfigChecker;
|
||||
311
push-receiver-service/database/init.sql
Normal file
311
push-receiver-service/database/init.sql
Normal file
@@ -0,0 +1,311 @@
|
||||
-- 推送消息接收服务数据库设计
|
||||
-- 创建数据库和基础表结构
|
||||
|
||||
-- 1. 创建数据库 (需要管理员权限执行)
|
||||
-- CREATE DATABASE push_messages;
|
||||
-- CREATE USER push_service WITH PASSWORD 'your_secure_password';
|
||||
-- GRANT ALL PRIVILEGES ON DATABASE push_messages TO push_service;
|
||||
|
||||
-- 使用数据库
|
||||
-- \c push_messages;
|
||||
|
||||
-- 2. 创建扩展
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||
|
||||
-- 3. 推送消息主表
|
||||
CREATE TABLE push_messages (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
message_id VARCHAR(255) UNIQUE, -- 外部消息ID(如果有)
|
||||
push_type VARCHAR(50) NOT NULL, -- 推送类型:SOS, HEALTH, LOCATION, ALERT 等
|
||||
user_id VARCHAR(255), -- 用户ID
|
||||
device_id VARCHAR(255), -- 设备ID
|
||||
source_ip INET, -- 来源IP地址
|
||||
user_agent TEXT, -- 用户代理
|
||||
|
||||
-- 消息内容(JSON格式存储原始数据)
|
||||
raw_data JSONB NOT NULL, -- 原始接收到的完整数据
|
||||
parsed_data JSONB, -- 解析后的结构化数据
|
||||
|
||||
-- 时间戳
|
||||
received_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 处理状态
|
||||
processing_status VARCHAR(20) DEFAULT 'pending', -- pending, processed, failed, ignored
|
||||
processed_at TIMESTAMP WITH TIME ZONE,
|
||||
error_message TEXT,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
|
||||
-- 优先级和分类
|
||||
priority INTEGER DEFAULT 5, -- 1-10,数字越小优先级越高
|
||||
category VARCHAR(100), -- 消息分类
|
||||
tags TEXT[], -- 标签数组
|
||||
|
||||
-- 验证和重复检查
|
||||
checksum VARCHAR(64), -- 消息校验和,用于去重
|
||||
is_duplicate BOOLEAN DEFAULT FALSE,
|
||||
original_message_id UUID, -- 如果是重复消息,指向原始消息ID
|
||||
|
||||
-- 地理位置信息(如果有)
|
||||
latitude DECIMAL(10, 8),
|
||||
longitude DECIMAL(11, 8),
|
||||
location_accuracy FLOAT,
|
||||
location_timestamp TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- 索引和搜索
|
||||
search_vector tsvector, -- 全文搜索向量
|
||||
|
||||
-- 软删除
|
||||
is_deleted BOOLEAN DEFAULT FALSE,
|
||||
deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- 4. 推送类型配置表
|
||||
CREATE TABLE push_types (
|
||||
id SERIAL PRIMARY KEY,
|
||||
type_code VARCHAR(50) UNIQUE NOT NULL,
|
||||
type_name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
default_priority INTEGER DEFAULT 5,
|
||||
validation_schema JSONB, -- JSON Schema 用于验证消息格式
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 5. 消息处理日志表
|
||||
CREATE TABLE message_processing_logs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
message_id UUID REFERENCES push_messages(id) ON DELETE CASCADE,
|
||||
processing_step VARCHAR(100) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL, -- started, completed, failed
|
||||
started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP WITH TIME ZONE,
|
||||
duration_ms INTEGER,
|
||||
details JSONB,
|
||||
error_details TEXT
|
||||
);
|
||||
|
||||
-- 6. 设备信息表
|
||||
CREATE TABLE devices (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
device_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
device_name VARCHAR(255),
|
||||
device_type VARCHAR(100), -- sensor, mobile, wearable, etc.
|
||||
user_id VARCHAR(255),
|
||||
last_seen_at TIMESTAMP WITH TIME ZONE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
metadata JSONB, -- 设备元数据
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 7. 用户信息表(简化)
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
user_name VARCHAR(255),
|
||||
user_type VARCHAR(50), -- student, teacher, elder, caregiver, etc.
|
||||
contact_info JSONB, -- 联系方式
|
||||
preferences JSONB, -- 用户偏好设置
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 8. 系统统计表
|
||||
CREATE TABLE system_stats (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
stat_date DATE DEFAULT CURRENT_DATE,
|
||||
stat_hour INTEGER DEFAULT EXTRACT(HOUR FROM CURRENT_TIMESTAMP),
|
||||
push_type VARCHAR(50),
|
||||
message_count INTEGER DEFAULT 0,
|
||||
success_count INTEGER DEFAULT 0,
|
||||
error_count INTEGER DEFAULT 0,
|
||||
avg_processing_time_ms FLOAT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(stat_date, stat_hour, push_type)
|
||||
);
|
||||
|
||||
-- 9. 创建索引
|
||||
CREATE INDEX idx_push_messages_received_at ON push_messages(received_at DESC);
|
||||
CREATE INDEX idx_push_messages_push_type ON push_messages(push_type);
|
||||
CREATE INDEX idx_push_messages_user_id ON push_messages(user_id);
|
||||
CREATE INDEX idx_push_messages_device_id ON push_messages(device_id);
|
||||
CREATE INDEX idx_push_messages_processing_status ON push_messages(processing_status);
|
||||
CREATE INDEX idx_push_messages_priority ON push_messages(priority);
|
||||
CREATE INDEX idx_push_messages_checksum ON push_messages(checksum);
|
||||
CREATE INDEX idx_push_messages_location ON push_messages USING GIST(ST_POINT(longitude, latitude)) WHERE latitude IS NOT NULL AND longitude IS NOT NULL;
|
||||
CREATE INDEX idx_push_messages_search_vector ON push_messages USING GIN(search_vector);
|
||||
CREATE INDEX idx_push_messages_raw_data ON push_messages USING GIN(raw_data);
|
||||
CREATE INDEX idx_push_messages_parsed_data ON push_messages USING GIN(parsed_data);
|
||||
|
||||
CREATE INDEX idx_processing_logs_message_id ON message_processing_logs(message_id);
|
||||
CREATE INDEX idx_processing_logs_started_at ON message_processing_logs(started_at DESC);
|
||||
|
||||
CREATE INDEX idx_devices_device_id ON devices(device_id);
|
||||
CREATE INDEX idx_devices_user_id ON devices(user_id);
|
||||
CREATE INDEX idx_devices_last_seen_at ON devices(last_seen_at DESC);
|
||||
|
||||
CREATE INDEX idx_users_user_id ON users(user_id);
|
||||
|
||||
CREATE INDEX idx_system_stats_date_hour ON system_stats(stat_date DESC, stat_hour DESC);
|
||||
CREATE INDEX idx_system_stats_push_type ON system_stats(push_type);
|
||||
|
||||
-- 10. 创建触发器函数
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_search_vector()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.search_vector := to_tsvector('english',
|
||||
COALESCE(NEW.push_type, '') || ' ' ||
|
||||
COALESCE(NEW.user_id, '') || ' ' ||
|
||||
COALESCE(NEW.device_id, '') || ' ' ||
|
||||
COALESCE(NEW.category, '') || ' ' ||
|
||||
COALESCE(array_to_string(NEW.tags, ' '), '') || ' ' ||
|
||||
COALESCE(NEW.raw_data::text, '')
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- 11. 创建触发器
|
||||
CREATE TRIGGER update_push_messages_updated_at
|
||||
BEFORE UPDATE ON push_messages
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_push_messages_search_vector
|
||||
BEFORE INSERT OR UPDATE ON push_messages
|
||||
FOR EACH ROW EXECUTE FUNCTION update_search_vector();
|
||||
|
||||
CREATE TRIGGER update_push_types_updated_at
|
||||
BEFORE UPDATE ON push_types
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_devices_updated_at
|
||||
BEFORE UPDATE ON devices
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_users_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- 12. 插入基础推送类型配置
|
||||
INSERT INTO push_types (type_code, type_name, description, default_priority, validation_schema) VALUES
|
||||
('SOS', '紧急求救', 'SOS紧急求救信号', 1, '{"type": "object", "required": ["userId"], "properties": {"userId": {"type": "string"}, "emergencyLevel": {"type": "string", "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"]}}}'),
|
||||
('HEALTH', '健康数据', '健康监测数据推送', 3, '{"type": "object", "required": ["userId"], "properties": {"userId": {"type": "string"}, "H": {"type": "number"}, "O": {"type": "number"}, "T": {"type": "number"}}}'),
|
||||
('LOCATION', '位置信息', '位置定位数据推送', 4, '{"type": "object", "required": ["userId"], "properties": {"userId": {"type": "string"}, "lat": {"type": "number"}, "lng": {"type": "number"}, "accuracy": {"type": "number"}}}'),
|
||||
('ALERT', '告警信息', '各类告警信息推送', 2, '{"type": "object", "required": ["userId", "alertType"], "properties": {"userId": {"type": "string"}, "alertType": {"type": "string"}, "severity": {"type": "string"}}}'),
|
||||
('ACTIVITY', '活动数据', '运动和活动数据推送', 5, '{"type": "object", "required": ["userId"], "properties": {"userId": {"type": "string"}, "activityType": {"type": "string"}, "duration": {"type": "number"}, "calories": {"type": "number"}}}'),
|
||||
('DEVICE_STATUS', '设备状态', '设备状态信息推送', 4, '{"type": "object", "required": ["deviceId"], "properties": {"deviceId": {"type": "string"}, "status": {"type": "string"}, "batteryLevel": {"type": "number"}}}');
|
||||
|
||||
-- 13. 创建视图
|
||||
-- 最近24小时消息统计视图
|
||||
CREATE VIEW recent_message_stats AS
|
||||
SELECT
|
||||
push_type,
|
||||
COUNT(*) as total_count,
|
||||
COUNT(*) FILTER (WHERE processing_status = 'processed') as processed_count,
|
||||
COUNT(*) FILTER (WHERE processing_status = 'failed') as failed_count,
|
||||
COUNT(*) FILTER (WHERE processing_status = 'pending') as pending_count,
|
||||
AVG(EXTRACT(EPOCH FROM (processed_at - received_at)) * 1000) as avg_processing_time_ms,
|
||||
MIN(received_at) as first_received,
|
||||
MAX(received_at) as last_received
|
||||
FROM push_messages
|
||||
WHERE received_at >= CURRENT_TIMESTAMP - INTERVAL '24 hours'
|
||||
AND is_deleted = FALSE
|
||||
GROUP BY push_type;
|
||||
|
||||
-- 活跃设备统计视图
|
||||
CREATE VIEW active_devices_stats AS
|
||||
SELECT
|
||||
device_type,
|
||||
COUNT(*) as total_devices,
|
||||
COUNT(*) FILTER (WHERE last_seen_at >= CURRENT_TIMESTAMP - INTERVAL '1 hour') as active_1h,
|
||||
COUNT(*) FILTER (WHERE last_seen_at >= CURRENT_TIMESTAMP - INTERVAL '24 hours') as active_24h,
|
||||
COUNT(*) FILTER (WHERE last_seen_at >= CURRENT_TIMESTAMP - INTERVAL '7 days') as active_7d
|
||||
FROM devices
|
||||
WHERE is_active = TRUE
|
||||
GROUP BY device_type;
|
||||
|
||||
-- 14. 创建函数
|
||||
-- 清理旧数据函数
|
||||
CREATE OR REPLACE FUNCTION cleanup_old_messages(days_to_keep INTEGER DEFAULT 30)
|
||||
RETURNS INTEGER AS $$
|
||||
DECLARE
|
||||
deleted_count INTEGER;
|
||||
BEGIN
|
||||
-- 软删除超过指定天数的消息
|
||||
UPDATE push_messages
|
||||
SET is_deleted = TRUE, deleted_at = CURRENT_TIMESTAMP
|
||||
WHERE received_at < CURRENT_TIMESTAMP - (days_to_keep || ' days')::INTERVAL
|
||||
AND is_deleted = FALSE;
|
||||
|
||||
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||
|
||||
-- 记录清理日志
|
||||
INSERT INTO message_processing_logs (message_id, processing_step, status, details)
|
||||
VALUES (NULL, 'cleanup_old_messages', 'completed',
|
||||
json_build_object('days_to_keep', days_to_keep, 'deleted_count', deleted_count));
|
||||
|
||||
RETURN deleted_count;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 获取消息统计函数
|
||||
CREATE OR REPLACE FUNCTION get_message_stats(hours_back INTEGER DEFAULT 24)
|
||||
RETURNS TABLE(
|
||||
push_type VARCHAR,
|
||||
total_count BIGINT,
|
||||
processed_count BIGINT,
|
||||
failed_count BIGINT,
|
||||
pending_count BIGINT,
|
||||
avg_processing_time_ms NUMERIC
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
pm.push_type,
|
||||
COUNT(*) as total_count,
|
||||
COUNT(*) FILTER (WHERE pm.processing_status = 'processed') as processed_count,
|
||||
COUNT(*) FILTER (WHERE pm.processing_status = 'failed') as failed_count,
|
||||
COUNT(*) FILTER (WHERE pm.processing_status = 'pending') as pending_count,
|
||||
AVG(EXTRACT(EPOCH FROM (pm.processed_at - pm.received_at)) * 1000) as avg_processing_time_ms
|
||||
FROM push_messages pm
|
||||
WHERE pm.received_at >= CURRENT_TIMESTAMP - (hours_back || ' hours')::INTERVAL
|
||||
AND pm.is_deleted = FALSE
|
||||
GROUP BY pm.push_type
|
||||
ORDER BY total_count DESC;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 15. 创建定时任务(需要pg_cron扩展)
|
||||
-- 每天凌晨2点清理30天前的数据
|
||||
-- SELECT cron.schedule('cleanup-old-messages', '0 2 * * *', 'SELECT cleanup_old_messages(30);');
|
||||
|
||||
-- 16. 权限设置
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO push_service;
|
||||
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO push_service;
|
||||
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO push_service;
|
||||
|
||||
-- 17. 初始化完成标记
|
||||
INSERT INTO system_stats (stat_date, stat_hour, push_type, message_count)
|
||||
VALUES (CURRENT_DATE, EXTRACT(HOUR FROM CURRENT_TIMESTAMP), 'SYSTEM', 0)
|
||||
ON CONFLICT (stat_date, stat_hour, push_type) DO NOTHING;
|
||||
|
||||
COMMENT ON DATABASE push_messages IS '推送消息接收服务数据库 - 存储所有接收到的推送消息及相关数据';
|
||||
COMMENT ON TABLE push_messages IS '推送消息主表 - 存储所有接收到的推送消息';
|
||||
COMMENT ON TABLE push_types IS '推送类型配置表 - 定义各种推送消息类型及其验证规则';
|
||||
COMMENT ON TABLE message_processing_logs IS '消息处理日志表 - 记录消息处理过程的详细日志';
|
||||
COMMENT ON TABLE devices IS '设备信息表 - 存储推送消息来源设备的信息';
|
||||
COMMENT ON TABLE users IS '用户信息表 - 存储用户基本信息';
|
||||
COMMENT ON TABLE system_stats IS '系统统计表 - 存储系统运行统计数据';
|
||||
318
push-receiver-service/database/supabase-init.sql
Normal file
318
push-receiver-service/database/supabase-init.sql
Normal file
@@ -0,0 +1,318 @@
|
||||
-- 推送消息接收服务 Supabase 数据库设计
|
||||
-- 在 Supabase Dashboard 的 SQL Editor 中执行此脚本
|
||||
-- 适配现有的 ak_users 和 ak_devices 表结构
|
||||
|
||||
-- 启用必要的扩展
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- 1. 推送消息主表
|
||||
CREATE TABLE IF NOT EXISTS ps_push_messages (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
message_id VARCHAR(255), -- 外部消息ID(如果有)
|
||||
push_type VARCHAR(50) NOT NULL, -- 推送类型:SOS, HEALTH, LOCATION, ALERT 等
|
||||
user_id UUID REFERENCES public.ak_users(id) ON DELETE SET NULL, -- 关联到现有用户表
|
||||
device_id UUID REFERENCES public.ak_devices(id) ON DELETE SET NULL, -- 关联到现有设备表
|
||||
source_ip INET, -- 来源IP地址
|
||||
user_agent TEXT, -- 用户代理
|
||||
|
||||
-- 消息内容(JSON格式存储原始数据)
|
||||
raw_data JSONB NOT NULL, -- 原始接收到的完整数据
|
||||
parsed_data JSONB, -- 解析后的结构化数据
|
||||
|
||||
-- 时间戳
|
||||
received_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
-- 处理状态
|
||||
processing_status VARCHAR(20) DEFAULT 'pending', -- pending, processed, failed, ignored
|
||||
processed_at TIMESTAMPTZ,
|
||||
error_message TEXT,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
|
||||
-- 优先级和分类
|
||||
priority INTEGER DEFAULT 5, -- 1-10,数字越小优先级越高
|
||||
category VARCHAR(100), -- 消息分类
|
||||
tags TEXT[], -- 标签数组
|
||||
|
||||
-- 验证和重复检查
|
||||
checksum VARCHAR(64), -- 消息校验和,用于去重
|
||||
is_duplicate BOOLEAN DEFAULT FALSE,
|
||||
original_message_id UUID, -- 如果是重复消息,指向原始消息ID
|
||||
|
||||
-- 地理位置信息(如果有)
|
||||
latitude DECIMAL(10, 8),
|
||||
longitude DECIMAL(11, 8),
|
||||
location_accuracy FLOAT,
|
||||
location_timestamp TIMESTAMPTZ,
|
||||
|
||||
-- 索引和搜索
|
||||
search_vector TSVECTOR, -- 全文搜索向量
|
||||
|
||||
-- 软删除
|
||||
is_deleted BOOLEAN DEFAULT FALSE,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
-- 约束
|
||||
CONSTRAINT unique_checksum UNIQUE (checksum),
|
||||
CONSTRAINT fk_original_message FOREIGN KEY (original_message_id) REFERENCES ps_push_messages(id)
|
||||
);
|
||||
|
||||
-- 2. 推送类型配置表
|
||||
CREATE TABLE IF NOT EXISTS ps_push_types (
|
||||
id SERIAL PRIMARY KEY,
|
||||
type_code VARCHAR(50) UNIQUE NOT NULL,
|
||||
type_name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
default_priority INTEGER DEFAULT 5,
|
||||
validation_schema JSONB, -- JSON Schema 用于验证消息格式
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 3. 消息处理日志表
|
||||
CREATE TABLE IF NOT EXISTS ps_message_processing_logs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
message_id UUID REFERENCES ps_push_messages(id) ON DELETE CASCADE,
|
||||
processing_step VARCHAR(100) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL, -- started, completed, failed
|
||||
started_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
duration_ms INTEGER,
|
||||
details JSONB,
|
||||
error_details TEXT
|
||||
);
|
||||
|
||||
-- 4. 设备信息表 - 已存在,使用现有的 ak_devices 表
|
||||
-- CREATE TABLE public.ak_devices (
|
||||
-- id UUID PRIMARY KEY,
|
||||
-- user_id UUID REFERENCES public.ak_users(id),
|
||||
-- device_type VARCHAR(32) NOT NULL,
|
||||
-- device_name VARCHAR(64),
|
||||
-- device_mac VARCHAR(64),
|
||||
-- bind_time TIMESTAMPTZ DEFAULT now(),
|
||||
-- status VARCHAR(16) DEFAULT 'active',
|
||||
-- extra JSONB
|
||||
-- );
|
||||
|
||||
-- 5. 用户信息表 - 已存在,使用现有的 ak_users 表
|
||||
-- CREATE TABLE public.ak_users (
|
||||
-- id UUID PRIMARY KEY,
|
||||
-- username VARCHAR(64) UNIQUE NOT NULL,
|
||||
-- email VARCHAR(128) UNIQUE NOT NULL,
|
||||
-- role VARCHAR(32) DEFAULT 'student',
|
||||
-- created_at TIMESTAMPTZ DEFAULT now(),
|
||||
-- ...其他字段
|
||||
-- );
|
||||
|
||||
-- 6. 系统统计表
|
||||
CREATE TABLE IF NOT EXISTS ps_system_stats (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
stat_date DATE DEFAULT CURRENT_DATE,
|
||||
stat_hour INTEGER DEFAULT EXTRACT(HOUR FROM NOW()),
|
||||
push_type VARCHAR(50),
|
||||
message_count INTEGER DEFAULT 0,
|
||||
success_count INTEGER DEFAULT 0,
|
||||
error_count INTEGER DEFAULT 0,
|
||||
avg_processing_time_ms FLOAT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(stat_date, stat_hour, push_type)
|
||||
);
|
||||
|
||||
-- 7. 创建索引
|
||||
CREATE INDEX IF NOT EXISTS idx_ps_push_messages_received_at ON ps_push_messages(received_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ps_push_messages_push_type ON ps_push_messages(push_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_ps_push_messages_user_id ON ps_push_messages(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ps_push_messages_device_id ON ps_push_messages(device_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ps_push_messages_processing_status ON ps_push_messages(processing_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_ps_push_messages_priority ON ps_push_messages(priority);
|
||||
CREATE INDEX IF NOT EXISTS idx_ps_push_messages_checksum ON ps_push_messages(checksum);
|
||||
CREATE INDEX IF NOT EXISTS idx_ps_push_messages_search_vector ON ps_push_messages USING GIN(search_vector);
|
||||
CREATE INDEX IF NOT EXISTS idx_ps_push_messages_raw_data ON ps_push_messages USING GIN(raw_data);
|
||||
CREATE INDEX IF NOT EXISTS idx_ps_push_messages_parsed_data ON ps_push_messages USING GIN(parsed_data);
|
||||
CREATE INDEX IF NOT EXISTS idx_ps_push_messages_location ON ps_push_messages(latitude, longitude) WHERE latitude IS NOT NULL AND longitude IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ps_processing_logs_message_id ON ps_message_processing_logs(message_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ps_processing_logs_started_at ON ps_message_processing_logs(started_at DESC);
|
||||
|
||||
-- 现有 ak_devices 和 ak_users 表的索引已存在,不需要重复创建
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ps_system_stats_date_hour ON ps_system_stats(stat_date DESC, stat_hour DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ps_system_stats_push_type ON ps_system_stats(push_type);
|
||||
|
||||
-- 8. 创建触发器函数
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_search_vector()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.search_vector := to_tsvector('english',
|
||||
COALESCE(NEW.push_type, '') || ' ' ||
|
||||
COALESCE(NEW.user_id, '') || ' ' ||
|
||||
COALESCE(NEW.device_id, '') || ' ' ||
|
||||
COALESCE(NEW.category, '') || ' ' ||
|
||||
COALESCE(array_to_string(NEW.tags, ' '), '') || ' ' ||
|
||||
COALESCE(NEW.raw_data::text, '')
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 9. 创建触发器
|
||||
DROP TRIGGER IF EXISTS update_ps_push_messages_updated_at ON ps_push_messages;
|
||||
CREATE TRIGGER update_ps_push_messages_updated_at
|
||||
BEFORE UPDATE ON ps_push_messages
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_ps_push_messages_search_vector ON ps_push_messages;
|
||||
CREATE TRIGGER update_ps_push_messages_search_vector
|
||||
BEFORE INSERT OR UPDATE ON ps_push_messages
|
||||
FOR EACH ROW EXECUTE FUNCTION update_search_vector();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_ps_push_types_updated_at ON ps_push_types;
|
||||
CREATE TRIGGER update_ps_push_types_updated_at
|
||||
BEFORE UPDATE ON ps_push_types
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- 现有的 ak_devices 和 ak_users 表可能已有更新时间触发器,不需要重复创建
|
||||
|
||||
-- 10. 插入基础推送类型配置
|
||||
INSERT INTO ps_push_types (type_code, type_name, description, default_priority, validation_schema) VALUES
|
||||
('SOS', '紧急求救', 'SOS紧急求救信号', 1, '{"type": "object", "required": ["userId"], "properties": {"userId": {"type": "string"}, "emergencyLevel": {"type": "string", "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"]}}}'),
|
||||
('HEALTH', '健康数据', '健康监测数据推送', 3, '{"type": "object", "required": ["userId"], "properties": {"userId": {"type": "string"}, "H": {"type": "number"}, "O": {"type": "number"}, "T": {"type": "number"}}}'),
|
||||
('LOCATION', '位置信息', '位置定位数据推送', 4, '{"type": "object", "required": ["userId"], "properties": {"userId": {"type": "string"}, "lat": {"type": "number"}, "lng": {"type": "number"}, "accuracy": {"type": "number"}}}'),
|
||||
('ALERT', '告警信息', '各类告警信息推送', 2, '{"type": "object", "required": ["userId", "alertType"], "properties": {"userId": {"type": "string"}, "alertType": {"type": "string"}, "severity": {"type": "string"}}}'),
|
||||
('ACTIVITY', '活动数据', '运动和活动数据推送', 5, '{"type": "object", "required": ["userId"], "properties": {"userId": {"type": "string"}, "activityType": {"type": "string"}, "duration": {"type": "number"}, "calories": {"type": "number"}}}'),
|
||||
('DEVICE_STATUS', '设备状态', '设备状态信息推送', 4, '{"type": "object", "required": ["deviceId"], "properties": {"deviceId": {"type": "string"}, "status": {"type": "string"}, "batteryLevel": {"type": "number"}}}')
|
||||
ON CONFLICT (type_code) DO NOTHING;
|
||||
|
||||
-- 11. 创建视图
|
||||
CREATE OR REPLACE VIEW ps_recent_message_stats AS
|
||||
SELECT
|
||||
push_type,
|
||||
COUNT(*) as total_count,
|
||||
COUNT(*) FILTER (WHERE processing_status = 'processed') as processed_count,
|
||||
COUNT(*) FILTER (WHERE processing_status = 'failed') as failed_count,
|
||||
COUNT(*) FILTER (WHERE processing_status = 'pending') as pending_count,
|
||||
AVG(EXTRACT(EPOCH FROM (processed_at - received_at)) * 1000) as avg_processing_time_ms,
|
||||
MIN(received_at) as first_received,
|
||||
MAX(received_at) as last_received
|
||||
FROM ps_push_messages
|
||||
WHERE received_at >= NOW() - INTERVAL '24 hours'
|
||||
AND is_deleted = FALSE
|
||||
GROUP BY push_type;
|
||||
|
||||
CREATE OR REPLACE VIEW ps_active_devices_stats AS
|
||||
SELECT
|
||||
d.device_type,
|
||||
COUNT(*) as total_devices,
|
||||
COUNT(*) FILTER (WHERE d.bind_time >= NOW() - INTERVAL '1 hour') as active_1h,
|
||||
COUNT(*) FILTER (WHERE d.bind_time >= NOW() - INTERVAL '24 hours') as active_24h,
|
||||
COUNT(*) FILTER (WHERE d.bind_time >= NOW() - INTERVAL '7 days') as active_7d
|
||||
FROM public.ak_devices d
|
||||
WHERE d.status = 'active'
|
||||
GROUP BY d.device_type;
|
||||
|
||||
-- 12. 创建函数
|
||||
CREATE OR REPLACE FUNCTION ps_cleanup_old_messages(days_to_keep INTEGER DEFAULT 30)
|
||||
RETURNS INTEGER AS $$
|
||||
DECLARE
|
||||
deleted_count INTEGER;
|
||||
BEGIN
|
||||
-- 软删除超过指定天数的消息
|
||||
UPDATE ps_push_messages
|
||||
SET is_deleted = TRUE, deleted_at = NOW()
|
||||
WHERE received_at < NOW() - (days_to_keep || ' days')::INTERVAL
|
||||
AND is_deleted = FALSE;
|
||||
|
||||
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||
|
||||
-- 记录清理日志
|
||||
INSERT INTO ps_message_processing_logs (message_id, processing_step, status, details)
|
||||
VALUES (NULL, 'cleanup_old_messages', 'completed',
|
||||
json_build_object('days_to_keep', days_to_keep, 'deleted_count', deleted_count));
|
||||
|
||||
RETURN deleted_count;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION ps_get_message_stats(hours_back INTEGER DEFAULT 24)
|
||||
RETURNS TABLE(
|
||||
push_type VARCHAR,
|
||||
total_count BIGINT,
|
||||
processed_count BIGINT,
|
||||
failed_count BIGINT,
|
||||
pending_count BIGINT,
|
||||
avg_processing_time_ms NUMERIC
|
||||
) AS $$
|
||||
BEGIN RETURN QUERY
|
||||
SELECT
|
||||
pm.push_type,
|
||||
COUNT(*) as total_count,
|
||||
COUNT(*) FILTER (WHERE pm.processing_status = 'processed') as processed_count,
|
||||
COUNT(*) FILTER (WHERE pm.processing_status = 'failed') as failed_count,
|
||||
COUNT(*) FILTER (WHERE pm.processing_status = 'pending') as pending_count,
|
||||
AVG(EXTRACT(EPOCH FROM (pm.processed_at - pm.received_at)) * 1000) as avg_processing_time_ms
|
||||
FROM ps_push_messages pm
|
||||
WHERE pm.received_at >= NOW() - (hours_back || ' hours')::INTERVAL
|
||||
AND pm.is_deleted = FALSE
|
||||
GROUP BY pm.push_type
|
||||
ORDER BY total_count DESC;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 13. 启用 Row Level Security (RLS)
|
||||
ALTER TABLE ps_push_messages ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE ps_push_types ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE ps_message_processing_logs ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE ps_system_stats ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 现有的 ak_devices 和 ak_users 表可能已有 RLS 设置
|
||||
|
||||
-- 14. 创建 RLS 策略(Service Role 可以访问所有数据)
|
||||
CREATE POLICY "Enable all access for service role" ON ps_push_messages
|
||||
FOR ALL USING (auth.role() = 'service_role');
|
||||
|
||||
CREATE POLICY "Enable all access for service role" ON ps_push_types
|
||||
FOR ALL USING (auth.role() = 'service_role');
|
||||
|
||||
CREATE POLICY "Enable all access for service role" ON ps_message_processing_logs
|
||||
FOR ALL USING (auth.role() = 'service_role');
|
||||
|
||||
CREATE POLICY "Enable all access for service role" ON ps_system_stats
|
||||
FOR ALL USING (auth.role() = 'service_role');
|
||||
|
||||
-- 现有的 ak_devices 和 ak_users 表可能已有 RLS 策略
|
||||
|
||||
-- 15. 创建 API 访问策略(允许匿名用户插入数据)
|
||||
CREATE POLICY "Enable insert for anon users" ON ps_push_messages
|
||||
FOR INSERT WITH CHECK (true);
|
||||
|
||||
CREATE POLICY "Enable read for anon users" ON ps_push_types
|
||||
FOR SELECT USING (is_active = true);
|
||||
|
||||
-- 16. 初始化完成标记
|
||||
INSERT INTO ps_system_stats (stat_date, stat_hour, push_type, message_count)
|
||||
VALUES (CURRENT_DATE, EXTRACT(HOUR FROM NOW()), 'SYSTEM', 0)
|
||||
ON CONFLICT (stat_date, stat_hour, push_type) DO NOTHING;
|
||||
|
||||
-- 17. 创建实时订阅(用于监控推送消息)
|
||||
-- 在 Supabase Dashboard 的 Database > Replication 中启用 ps_push_messages 表的实时功能
|
||||
|
||||
-- 注释
|
||||
COMMENT ON TABLE ps_push_messages IS '推送消息主表 - 存储所有接收到的推送消息';
|
||||
COMMENT ON TABLE ps_push_types IS '推送类型配置表 - 定义各种推送消息类型及其验证规则';
|
||||
COMMENT ON TABLE ps_message_processing_logs IS '消息处理日志表 - 记录消息处理过程的详细日志';
|
||||
COMMENT ON TABLE ps_system_stats IS '系统统计表 - 存储系统运行统计数据';
|
||||
|
||||
-- 现有表的注释:
|
||||
-- public.ak_devices: 设备表 - 存储用户绑定的各种设备信息
|
||||
-- public.ak_users: 用户表 - 存储系统用户的基本信息和配置
|
||||
22
push-receiver-service/ed.md
Normal file
22
push-receiver-service/ed.md
Normal file
@@ -0,0 +1,22 @@
|
||||
手表imei:860100005876385
|
||||
|
||||
|
||||
{
|
||||
"appId": "NS4yMDQ2NDAzNA==",
|
||||
"appKey": "9BC1BB6DF0993D167C6E0327CBDF5F1E",
|
||||
"deviceList": "860100005876385"
|
||||
}
|
||||
|
||||
|
||||
|
||||
forever start -c "npm start" .
|
||||
|
||||
forever start -c "npm start" /path/to/your/project
|
||||
forever list
|
||||
|
||||
forever stop <index|script>
|
||||
|
||||
forever logs
|
||||
|
||||
# 或直接查看日志文件
|
||||
cat ~/.forever/*.log
|
||||
427
push-receiver-service/lib/database.js
Normal file
427
push-receiver-service/lib/database.js
Normal file
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* 数据库连接和操作类
|
||||
* 提供 PostgreSQL 数据库的连接池和基础操作方法
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
const winston = require('winston');
|
||||
require('dotenv').config();
|
||||
|
||||
class DatabaseManager {
|
||||
constructor() {
|
||||
this.pool = null;
|
||||
this.logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console(),
|
||||
new winston.transports.File({ filename: 'logs/database.log' })
|
||||
]
|
||||
});
|
||||
|
||||
this.initializePool();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化数据库连接池
|
||||
*/
|
||||
initializePool() {
|
||||
try {
|
||||
this.pool = new Pool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
database: process.env.DB_NAME || 'push_messages',
|
||||
user: process.env.DB_USER || 'push_service',
|
||||
password: process.env.DB_PASSWORD,
|
||||
ssl: process.env.DB_SSL === 'true',
|
||||
max: parseInt(process.env.DB_MAX_CONNECTIONS) || 20,
|
||||
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT) || 30000,
|
||||
connectionTimeoutMillis: parseInt(process.env.DB_CONNECTION_TIMEOUT) || 5000,
|
||||
});
|
||||
|
||||
// 监听连接池事件
|
||||
this.pool.on('connect', (client) => {
|
||||
this.logger.info('数据库连接已建立', {
|
||||
totalCount: this.pool.totalCount,
|
||||
idleCount: this.pool.idleCount,
|
||||
waitingCount: this.pool.waitingCount
|
||||
});
|
||||
});
|
||||
|
||||
this.pool.on('error', (err, client) => {
|
||||
this.logger.error('数据库连接池错误', { error: err.message, stack: err.stack });
|
||||
});
|
||||
|
||||
this.logger.info('数据库连接池初始化完成');
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('数据库连接池初始化失败', { error: error.message, stack: error.stack });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试数据库连接
|
||||
*/
|
||||
async testConnection() {
|
||||
try {
|
||||
const client = await this.pool.connect();
|
||||
const result = await client.query('SELECT NOW() as current_time, version() as version');
|
||||
client.release();
|
||||
|
||||
this.logger.info('数据库连接测试成功', {
|
||||
currentTime: result.rows[0].current_time,
|
||||
version: result.rows[0].version.split(' ')[0]
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
currentTime: result.rows[0].current_time,
|
||||
version: result.rows[0].version
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('数据库连接测试失败', { error: error.message, stack: error.stack });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 插入推送消息
|
||||
*/
|
||||
async insertPushMessage(messageData) {
|
||||
const startTime = Date.now();
|
||||
const client = await this.pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// 生成消息校验和用于去重
|
||||
const checksum = this.generateChecksum(messageData);
|
||||
|
||||
// 检查是否存在重复消息
|
||||
const duplicateCheck = await client.query(
|
||||
'SELECT id FROM push_messages WHERE checksum = $1 AND is_deleted = FALSE LIMIT 1',
|
||||
[checksum]
|
||||
);
|
||||
|
||||
let messageId;
|
||||
let isDuplicate = false;
|
||||
|
||||
if (duplicateCheck.rows.length > 0) {
|
||||
// 处理重复消息
|
||||
isDuplicate = true;
|
||||
const originalMessageId = duplicateCheck.rows[0].id;
|
||||
|
||||
const insertQuery = `
|
||||
INSERT INTO push_messages (
|
||||
message_id, push_type, user_id, device_id, source_ip, user_agent,
|
||||
raw_data, parsed_data, priority, category, tags, checksum,
|
||||
is_duplicate, original_message_id, latitude, longitude,
|
||||
location_accuracy, location_timestamp
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18
|
||||
) RETURNING id
|
||||
`;
|
||||
|
||||
const result = await client.query(insertQuery, [
|
||||
messageData.message_id || null,
|
||||
messageData.push_type || messageData.pushType,
|
||||
messageData.user_id || messageData.userId,
|
||||
messageData.device_id || messageData.deviceId,
|
||||
messageData.source_ip,
|
||||
messageData.user_agent,
|
||||
JSON.stringify(messageData.raw_data || messageData),
|
||||
JSON.stringify(messageData.parsed_data || messageData),
|
||||
messageData.priority || 5,
|
||||
messageData.category,
|
||||
messageData.tags,
|
||||
checksum,
|
||||
true,
|
||||
originalMessageId,
|
||||
messageData.latitude || messageData.lat,
|
||||
messageData.longitude || messageData.lng,
|
||||
messageData.location_accuracy || messageData.accuracy,
|
||||
messageData.location_timestamp || null
|
||||
]);
|
||||
|
||||
messageId = result.rows[0].id;
|
||||
|
||||
} else {
|
||||
// 插入新消息
|
||||
const insertQuery = `
|
||||
INSERT INTO push_messages (
|
||||
message_id, push_type, user_id, device_id, source_ip, user_agent,
|
||||
raw_data, parsed_data, priority, category, tags, checksum,
|
||||
is_duplicate, latitude, longitude, location_accuracy, location_timestamp
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17
|
||||
) RETURNING id
|
||||
`;
|
||||
|
||||
const result = await client.query(insertQuery, [
|
||||
messageData.message_id || null,
|
||||
messageData.push_type || messageData.pushType,
|
||||
messageData.user_id || messageData.userId,
|
||||
messageData.device_id || messageData.deviceId,
|
||||
messageData.source_ip,
|
||||
messageData.user_agent,
|
||||
JSON.stringify(messageData.raw_data || messageData),
|
||||
JSON.stringify(messageData.parsed_data || messageData),
|
||||
messageData.priority || 5,
|
||||
messageData.category,
|
||||
messageData.tags,
|
||||
checksum,
|
||||
false,
|
||||
messageData.latitude || messageData.lat,
|
||||
messageData.longitude || messageData.lng,
|
||||
messageData.location_accuracy || messageData.accuracy,
|
||||
messageData.location_timestamp || null
|
||||
]);
|
||||
|
||||
messageId = result.rows[0].id;
|
||||
}
|
||||
|
||||
// 记录处理日志
|
||||
await client.query(
|
||||
`INSERT INTO message_processing_logs (message_id, processing_step, status, started_at, completed_at, duration_ms, details)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
messageId,
|
||||
'message_received',
|
||||
'completed',
|
||||
new Date(startTime),
|
||||
new Date(),
|
||||
Date.now() - startTime,
|
||||
JSON.stringify({ isDuplicate, checksum })
|
||||
]
|
||||
);
|
||||
|
||||
// 更新设备信息
|
||||
if (messageData.device_id || messageData.deviceId) {
|
||||
await this.upsertDevice(client, {
|
||||
device_id: messageData.device_id || messageData.deviceId,
|
||||
device_name: messageData.device_name,
|
||||
device_type: messageData.device_type,
|
||||
user_id: messageData.user_id || messageData.userId,
|
||||
metadata: messageData.device_metadata
|
||||
});
|
||||
}
|
||||
|
||||
// 更新用户信息
|
||||
if (messageData.user_id || messageData.userId) {
|
||||
await this.upsertUser(client, {
|
||||
user_id: messageData.user_id || messageData.userId,
|
||||
user_name: messageData.user_name,
|
||||
user_type: messageData.user_type
|
||||
});
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
this.logger.info('推送消息插入成功', {
|
||||
messageId,
|
||||
pushType: messageData.push_type || messageData.pushType,
|
||||
userId: messageData.user_id || messageData.userId,
|
||||
isDuplicate,
|
||||
processingTime: Date.now() - startTime
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId,
|
||||
isDuplicate,
|
||||
processingTime: Date.now() - startTime
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
this.logger.error('推送消息插入失败', { error: error.message, stack: error.stack });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量插入推送消息
|
||||
*/
|
||||
async insertPushMessagesBatch(messagesData) {
|
||||
const startTime = Date.now();
|
||||
const client = await this.pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const messageData of messagesData) {
|
||||
try {
|
||||
const result = await this.insertPushMessage(messageData);
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
results.push({
|
||||
success: false,
|
||||
error: error.message,
|
||||
messageData: messageData
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
const failureCount = results.filter(r => !r.success).length;
|
||||
|
||||
this.logger.info('批量推送消息插入完成', {
|
||||
totalCount: messagesData.length,
|
||||
successCount,
|
||||
failureCount,
|
||||
processingTime: Date.now() - startTime
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
totalCount: messagesData.length,
|
||||
successCount,
|
||||
failureCount,
|
||||
results,
|
||||
processingTime: Date.now() - startTime
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
this.logger.error('批量推送消息插入失败', { error: error.message, stack: error.stack });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新或插入设备信息
|
||||
*/
|
||||
async upsertDevice(client, deviceData) {
|
||||
const query = `
|
||||
INSERT INTO devices (device_id, device_name, device_type, user_id, last_seen_at, metadata)
|
||||
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP, $5)
|
||||
ON CONFLICT (device_id)
|
||||
DO UPDATE SET
|
||||
device_name = COALESCE(EXCLUDED.device_name, devices.device_name),
|
||||
device_type = COALESCE(EXCLUDED.device_type, devices.device_type),
|
||||
user_id = COALESCE(EXCLUDED.user_id, devices.user_id),
|
||||
last_seen_at = CURRENT_TIMESTAMP,
|
||||
metadata = COALESCE(EXCLUDED.metadata, devices.metadata),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`;
|
||||
|
||||
await client.query(query, [
|
||||
deviceData.device_id,
|
||||
deviceData.device_name,
|
||||
deviceData.device_type,
|
||||
deviceData.user_id,
|
||||
JSON.stringify(deviceData.metadata || {})
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新或插入用户信息
|
||||
*/
|
||||
async upsertUser(client, userData) {
|
||||
const query = `
|
||||
INSERT INTO users (user_id, user_name, user_type)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET
|
||||
user_name = COALESCE(EXCLUDED.user_name, users.user_name),
|
||||
user_type = COALESCE(EXCLUDED.user_type, users.user_type),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`;
|
||||
|
||||
await client.query(query, [
|
||||
userData.user_id,
|
||||
userData.user_name,
|
||||
userData.user_type
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成消息校验和
|
||||
*/
|
||||
generateChecksum(data) {
|
||||
const crypto = require('crypto');
|
||||
const normalizedData = JSON.stringify(data, Object.keys(data).sort());
|
||||
return crypto.createHash('sha256').update(normalizedData).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息统计信息
|
||||
*/
|
||||
async getMessageStats(hoursBack = 24) {
|
||||
try {
|
||||
const query = 'SELECT * FROM get_message_stats($1)';
|
||||
const result = await this.pool.query(query, [hoursBack]);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
this.logger.error('获取消息统计失败', { error: error.message, stack: error.stack });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统健康状态
|
||||
*/
|
||||
async getHealthStatus() {
|
||||
try {
|
||||
const poolStats = {
|
||||
totalCount: this.pool.totalCount,
|
||||
idleCount: this.pool.idleCount,
|
||||
waitingCount: this.pool.waitingCount
|
||||
};
|
||||
|
||||
const dbStats = await this.pool.query(`
|
||||
SELECT
|
||||
COUNT(*) as total_messages,
|
||||
COUNT(*) FILTER (WHERE received_at >= CURRENT_TIMESTAMP - INTERVAL '1 hour') as messages_last_hour,
|
||||
COUNT(*) FILTER (WHERE processing_status = 'pending') as pending_messages,
|
||||
COUNT(*) FILTER (WHERE processing_status = 'failed') as failed_messages
|
||||
FROM push_messages
|
||||
WHERE is_deleted = FALSE
|
||||
`);
|
||||
|
||||
return {
|
||||
database: {
|
||||
connected: true,
|
||||
pool: poolStats,
|
||||
stats: dbStats.rows[0]
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('获取系统健康状态失败', { error: error.message, stack: error.stack });
|
||||
return {
|
||||
database: {
|
||||
connected: false,
|
||||
error: error.message
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭数据库连接池
|
||||
*/
|
||||
async close() {
|
||||
if (this.pool) {
|
||||
await this.pool.end();
|
||||
this.logger.info('数据库连接池已关闭');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DatabaseManager;
|
||||
571
push-receiver-service/lib/supabase-database.js
Normal file
571
push-receiver-service/lib/supabase-database.js
Normal file
@@ -0,0 +1,571 @@
|
||||
/**
|
||||
* Supabase 数据库连接和操作类
|
||||
* 提供基于 Supabase 的数据库操作方法
|
||||
*/
|
||||
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
const winston = require('winston');
|
||||
const crypto = require('crypto');
|
||||
require('dotenv').config();
|
||||
|
||||
class SupabaseDatabaseManager {
|
||||
constructor() {
|
||||
this.supabase = null;
|
||||
this.logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console(),
|
||||
new winston.transports.File({ filename: 'logs/database.log' })
|
||||
]
|
||||
});
|
||||
|
||||
this.initializeClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 Supabase 客户端
|
||||
*/
|
||||
initializeClient() {
|
||||
try {
|
||||
const supabaseUrl = process.env.SUPABASE_URL;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseKey) {
|
||||
throw new Error('Supabase URL 和 Service Role Key 必须在环境变量中设置');
|
||||
}
|
||||
const eldercare_user = process.env.ELDERCARE_USER;
|
||||
const eldercare_password = process.env.ELDERCARE_PASSWORD;
|
||||
this.supabase = createClient(supabaseUrl, supabaseKey, {
|
||||
auth: {
|
||||
autoRefreshToken: true,
|
||||
persistSession: true
|
||||
},
|
||||
db: {
|
||||
schema: 'public'
|
||||
}
|
||||
});
|
||||
this.supabase.auth.signInWithPassword({
|
||||
email: eldercare_user,
|
||||
password: eldercare_password
|
||||
}).then(({ data, error }) => {
|
||||
if (error) {
|
||||
this.logger.error('Supabase 用户认证失败', {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.info('Supabase 客户端初始化完成', {
|
||||
url: supabaseUrl,
|
||||
hasServiceKey: !!supabaseKey
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('Supabase 客户端初始化失败', {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试数据库连接
|
||||
*/
|
||||
async testConnection() {
|
||||
try {
|
||||
const { data, error } = await this.supabase
|
||||
.from('ps_push_types')
|
||||
.select('count')
|
||||
.limit(1);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.info('Supabase 数据库连接测试成功');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Supabase 数据库连接正常',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('Supabase 数据库连接测试失败', {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 插入推送消息
|
||||
*/
|
||||
async insertPushMessage(messageData) {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 生成消息校验和用于去重
|
||||
const checksum = this.generateChecksum(messageData);
|
||||
// 检查是否存在重复消息
|
||||
const { data: duplicateData } = await this.supabase
|
||||
.from('ps_push_msg_raw')
|
||||
.select('id')
|
||||
.eq('checksum', checksum)
|
||||
.eq('is_deleted', false)
|
||||
.limit(1);
|
||||
|
||||
let isDuplicate = false;
|
||||
let originalMessageId = null;
|
||||
|
||||
if (duplicateData && duplicateData.length > 0) {
|
||||
isDuplicate = true;
|
||||
originalMessageId = duplicateData[0].id;
|
||||
}
|
||||
console.log('isDuplicate', isDuplicate, 'originalMessageId', originalMessageId);
|
||||
// 准备插入数据
|
||||
const insertData = {
|
||||
checksum: checksum,
|
||||
push_type: messageData.push_type || messageData.pushType,
|
||||
source_ip: messageData.source_ip,
|
||||
user_agent: messageData.user_agent,
|
||||
raw_data: messageData.raw_data || messageData
|
||||
};
|
||||
console.log('insertData', insertData);
|
||||
// 插入推送消息
|
||||
const { data: messageResult, error: messageError } = await this.supabase
|
||||
.from('ps_push_msg_raw')
|
||||
.insert(insertData)
|
||||
.select('id')
|
||||
.single();
|
||||
console.log('messageResult', messageResult);
|
||||
// 检查插入结果
|
||||
if (messageError) {
|
||||
this.logger.error('插入推送消息失败', {
|
||||
error: messageError.message,
|
||||
data: insertData
|
||||
});
|
||||
throw messageError;
|
||||
}
|
||||
|
||||
const messageId = messageResult.id;
|
||||
|
||||
// 记录处理日志
|
||||
await this.supabase
|
||||
.from('message_processing_logs')
|
||||
.insert({
|
||||
message_id: messageId,
|
||||
processing_step: 'message_received',
|
||||
status: 'completed',
|
||||
started_at: new Date(startTime).toISOString(),
|
||||
completed_at: new Date().toISOString(),
|
||||
duration_ms: Date.now() - startTime,
|
||||
details: { isDuplicate, checksum }
|
||||
});
|
||||
|
||||
// 更新设备信息
|
||||
if (messageData.device_id || messageData.deviceId) {
|
||||
await this.upsertDevice({
|
||||
device_id: messageData.device_id || messageData.deviceId,
|
||||
device_name: messageData.device_name,
|
||||
device_type: messageData.device_type,
|
||||
user_id: messageData.user_id || messageData.userId,
|
||||
metadata: messageData.device_metadata || {}
|
||||
});
|
||||
}
|
||||
|
||||
// 更新用户信息
|
||||
if (messageData.user_id || messageData.userId) {
|
||||
await this.upsertUser({
|
||||
user_id: messageData.user_id || messageData.userId,
|
||||
user_name: messageData.user_name,
|
||||
user_type: messageData.user_type
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.info('推送消息插入成功', {
|
||||
messageId,
|
||||
pushType: messageData.push_type || messageData.pushType,
|
||||
userId: messageData.user_id || messageData.userId,
|
||||
isDuplicate,
|
||||
processingTime: Date.now() - startTime
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId,
|
||||
isDuplicate,
|
||||
processingTime: Date.now() - startTime
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('推送消息插入失败', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
messageData: messageData
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量插入推送消息
|
||||
*/
|
||||
async insertPushMessagesBatch(messagesData) {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const results = [];
|
||||
|
||||
// 使用事务批量插入
|
||||
for (const messageData of messagesData) {
|
||||
try {
|
||||
const result = await this.insertPushMessage(messageData);
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
results.push({
|
||||
success: false,
|
||||
error: error.message,
|
||||
messageData: messageData
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
const failureCount = results.filter(r => !r.success).length;
|
||||
|
||||
this.logger.info('批量推送消息插入完成', {
|
||||
totalCount: messagesData.length,
|
||||
successCount,
|
||||
failureCount,
|
||||
processingTime: Date.now() - startTime
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
totalCount: messagesData.length,
|
||||
successCount,
|
||||
failureCount,
|
||||
results,
|
||||
processingTime: Date.now() - startTime
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('批量推送消息插入失败', {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 更新或插入设备信息 - 适配现有 ak_devices 表
|
||||
*/
|
||||
async upsertDevice(deviceData) {
|
||||
try {
|
||||
// 检查设备是否存在
|
||||
const { data: existingDevice, error: selectError } = await this.supabase
|
||||
.from('ak_devices')
|
||||
.select('id')
|
||||
.eq('id', deviceData.device_id)
|
||||
.single();
|
||||
|
||||
if (selectError && selectError.code !== 'PGRST116') { // PGRST116 是没有找到记录的错误
|
||||
throw selectError;
|
||||
}
|
||||
|
||||
// 如果设备存在,更新状态;如果不存在,记录日志(设备应该通过其他接口创建)
|
||||
if (existingDevice) {
|
||||
const { error } = await this.supabase
|
||||
.from('ak_devices')
|
||||
.update({
|
||||
status: 'active',
|
||||
extra: {
|
||||
...deviceData.metadata,
|
||||
last_push_at: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
.eq('id', deviceData.device_id);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.info('设备状态更新成功', { device_id: deviceData.device_id });
|
||||
} else {
|
||||
this.logger.warn('推送消息关联的设备不存在', {
|
||||
device_id: deviceData.device_id,
|
||||
message: '设备需要通过设备管理接口先创建'
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('设备信息更新失败', {
|
||||
error: error.message,
|
||||
deviceData: deviceData
|
||||
});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 更新或插入用户信息 - 适配现有 ak_users 表
|
||||
*/
|
||||
async upsertUser(userData) {
|
||||
try {
|
||||
// 检查用户是否存在
|
||||
const { data: existingUser, error: selectError } = await this.supabase
|
||||
.from('ak_users')
|
||||
.select('id')
|
||||
.eq('id', userData.user_id)
|
||||
.single();
|
||||
|
||||
if (selectError && selectError.code !== 'PGRST116') { // PGRST116 是没有找到记录的错误
|
||||
throw selectError;
|
||||
}
|
||||
|
||||
// 如果用户存在,记录活跃状态;如果不存在,记录日志(用户应该通过其他接口创建)
|
||||
if (existingUser) {
|
||||
this.logger.info('用户推送消息活跃', { user_id: userData.user_id });
|
||||
} else {
|
||||
this.logger.warn('推送消息关联的用户不存在', {
|
||||
user_id: userData.user_id,
|
||||
message: '用户需要通过用户管理接口先创建'
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('用户信息检查失败', {
|
||||
error: error.message,
|
||||
userData: userData
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成消息校验和
|
||||
*/
|
||||
generateChecksum(data) {
|
||||
const normalizedData = JSON.stringify(data, Object.keys(data).sort());
|
||||
return crypto.createHash('sha256').update(normalizedData).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息统计信息
|
||||
*/
|
||||
async getMessageStats(hoursBack = 24) {
|
||||
try {
|
||||
const { data, error } = await this.supabase
|
||||
.rpc('get_message_stats', { hours_back: hoursBack });
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data || [];
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('获取消息统计失败', {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统健康状态
|
||||
*/
|
||||
async getHealthStatus() {
|
||||
try { // 获取消息统计
|
||||
const { data: messageStats, error: statsError } = await this.supabase
|
||||
.from('ps_push_messages')
|
||||
.select('processing_status', { count: 'exact' })
|
||||
.gte('received_at', new Date(Date.now() - 60 * 60 * 1000).toISOString())
|
||||
.eq('is_deleted', false);
|
||||
|
||||
if (statsError) {
|
||||
throw statsError;
|
||||
}
|
||||
|
||||
// 统计不同状态的消息数量
|
||||
const stats = {
|
||||
total_messages: 0,
|
||||
messages_last_hour: messageStats?.length || 0,
|
||||
pending_messages: 0,
|
||||
failed_messages: 0,
|
||||
processed_messages: 0
|
||||
};
|
||||
// 获取总消息数
|
||||
const { count: totalCount } = await this.supabase
|
||||
.from('ps_push_messages')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('is_deleted', false);
|
||||
|
||||
stats.total_messages = totalCount || 0;
|
||||
|
||||
// 获取待处理和失败的消息数
|
||||
const { count: pendingCount } = await this.supabase
|
||||
.from('ps_push_messages')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('processing_status', 'pending')
|
||||
.eq('is_deleted', false);
|
||||
|
||||
const { count: failedCount } = await this.supabase
|
||||
.from('ps_push_messages')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('processing_status', 'failed')
|
||||
.eq('is_deleted', false);
|
||||
|
||||
stats.pending_messages = pendingCount || 0;
|
||||
stats.failed_messages = failedCount || 0;
|
||||
stats.processed_messages = stats.total_messages - stats.pending_messages - stats.failed_messages;
|
||||
|
||||
return {
|
||||
database: {
|
||||
connected: true,
|
||||
provider: 'Supabase',
|
||||
stats: stats
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('获取系统健康状态失败', {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
return {
|
||||
database: {
|
||||
connected: false,
|
||||
provider: 'Supabase',
|
||||
error: error.message
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取推送消息列表
|
||||
*/
|
||||
async getPushMessages(options = {}) {
|
||||
try {
|
||||
const {
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
pushType = null,
|
||||
userId = null,
|
||||
startDate = null,
|
||||
endDate = null,
|
||||
status = null
|
||||
} = options;
|
||||
let query = this.supabase
|
||||
.from('ps_push_messages')
|
||||
.select(`
|
||||
id,
|
||||
push_type,
|
||||
user_id,
|
||||
device_id,
|
||||
received_at,
|
||||
processing_status,
|
||||
priority,
|
||||
raw_data,
|
||||
is_duplicate
|
||||
`)
|
||||
.eq('is_deleted', false)
|
||||
.order('received_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (pushType) {
|
||||
query = query.eq('push_type', pushType);
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
query = query.eq('user_id', userId);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
query = query.eq('processing_status', status);
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
query = query.gte('received_at', startDate);
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
query = query.lte('received_at', endDate);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data || [];
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('获取推送消息列表失败', {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理旧数据
|
||||
*/
|
||||
async cleanupOldMessages(daysToKeep = 30) {
|
||||
try {
|
||||
const cutoffDate = new Date(Date.now() - daysToKeep * 24 * 60 * 60 * 1000).toISOString();
|
||||
const { data, error } = await this.supabase
|
||||
.from('ps_push_messages')
|
||||
.update({
|
||||
is_deleted: true,
|
||||
deleted_at: new Date().toISOString()
|
||||
})
|
||||
.lt('received_at', cutoffDate)
|
||||
.eq('is_deleted', false)
|
||||
.select('id');
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const deletedCount = data?.length || 0;
|
||||
|
||||
this.logger.info('清理旧数据完成', {
|
||||
daysToKeep,
|
||||
deletedCount,
|
||||
cutoffDate
|
||||
});
|
||||
|
||||
return deletedCount;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('清理旧数据失败', {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭连接(Supabase 客户端不需要显式关闭)
|
||||
*/
|
||||
async close() {
|
||||
this.logger.info('Supabase 客户端连接已关闭');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SupabaseDatabaseManager;
|
||||
1772
push-receiver-service/package-lock.json
generated
Normal file
1772
push-receiver-service/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
push-receiver-service/package.json
Normal file
42
push-receiver-service/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "push-receiver-service",
|
||||
"version": "1.0.0",
|
||||
"description": "专门接收推送消息的独立服务",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node supabase-server.js",
|
||||
"start:pg": "node server.js",
|
||||
"dev": "nodemon supabase-server.js",
|
||||
"dev:pg": "nodemon server.js",
|
||||
"setup-supabase": "node setup-supabase.js",
|
||||
"setup-pg": "node setup-database.js",
|
||||
"test": "node test.js",
|
||||
"check-config": "node check-config.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^8.1.0",
|
||||
"express-rate-limit": "^7.5.1",
|
||||
"winston": "^3.17.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"uuid": "^11.1.0",
|
||||
"joi": "^17.13.3",
|
||||
"compression": "^1.8.0",
|
||||
"express-validator": "^7.2.1",
|
||||
"@supabase/supabase-js": "^2.50.2",
|
||||
"crypto": "^1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.10"
|
||||
},
|
||||
"keywords": [
|
||||
"push",
|
||||
"receiver",
|
||||
"service",
|
||||
"api",
|
||||
"database"
|
||||
],
|
||||
"author": "Your Name",
|
||||
"license": "MIT"
|
||||
}
|
||||
556
push-receiver-service/server.js
Normal file
556
push-receiver-service/server.js
Normal file
@@ -0,0 +1,556 @@
|
||||
/**
|
||||
* 推送消息接收服务主服务器
|
||||
* 专门用于接收和存储各种推送消息
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const compression = require('compression');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const winston = require('winston');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
require('dotenv').config();
|
||||
|
||||
const DatabaseManager = require('./lib/database');
|
||||
|
||||
class PushReceiverService {
|
||||
constructor() {
|
||||
this.app = express();
|
||||
this.port = process.env.PORT || 3001;
|
||||
this.host = process.env.HOST || '0.0.0.0';
|
||||
this.db = new DatabaseManager();
|
||||
|
||||
this.setupLogger();
|
||||
this.setupMiddleware();
|
||||
this.setupRoutes();
|
||||
this.setupErrorHandling();
|
||||
|
||||
// 统计信息
|
||||
this.stats = {
|
||||
startTime: new Date(),
|
||||
requestCount: 0,
|
||||
messageCount: 0,
|
||||
errorCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置日志系统
|
||||
*/
|
||||
setupLogger() {
|
||||
// 确保日志目录存在
|
||||
const logDir = process.env.LOG_DIR || './logs';
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
|
||||
this.logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'push-service-error.log'),
|
||||
level: 'error',
|
||||
maxsize: parseInt(process.env.LOG_FILE_MAX_SIZE) || 10485760,
|
||||
maxFiles: parseInt(process.env.LOG_FILE_MAX_FILES) || 5
|
||||
}),
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'push-service.log'),
|
||||
maxsize: parseInt(process.env.LOG_FILE_MAX_SIZE) || 10485760,
|
||||
maxFiles: parseInt(process.env.LOG_FILE_MAX_FILES) || 5
|
||||
}),
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
)
|
||||
})
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置中间件
|
||||
*/
|
||||
setupMiddleware() {
|
||||
// 安全中间件
|
||||
this.app.use(helmet({
|
||||
contentSecurityPolicy: false
|
||||
}));
|
||||
|
||||
// 压缩中间件
|
||||
this.app.use(compression());
|
||||
|
||||
// CORS 配置
|
||||
const corsOptions = {
|
||||
origin: (origin, callback) => {
|
||||
const allowedOrigins = process.env.ALLOWED_ORIGINS;
|
||||
if (!allowedOrigins || allowedOrigins === '*') {
|
||||
callback(null, true);
|
||||
} else {
|
||||
const origins = allowedOrigins.split(',');
|
||||
if (origins.includes(origin) || !origin) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error('不允许的CORS来源'));
|
||||
}
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
optionsSuccessStatus: 200
|
||||
};
|
||||
this.app.use(cors(corsOptions));
|
||||
|
||||
// 请求体解析
|
||||
this.app.use(express.json({
|
||||
limit: process.env.MAX_MESSAGE_SIZE || '1mb'
|
||||
}));
|
||||
this.app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// API密钥验证中间件
|
||||
this.app.use('/api', (req, res, next) => {
|
||||
if (req.path === '/health') {
|
||||
return next(); // 健康检查不需要验证
|
||||
}
|
||||
|
||||
const apiKey = req.headers['x-api-key'] || req.headers['authorization'];
|
||||
const expectedApiKey = process.env.API_KEY;
|
||||
|
||||
if (expectedApiKey && (!apiKey || apiKey !== expectedApiKey)) {
|
||||
this.stats.errorCount++;
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'UNAUTHORIZED',
|
||||
message: 'API密钥无效或缺失'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// 速率限制
|
||||
const limiter = rateLimit({
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 60000,
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 1000,
|
||||
message: {
|
||||
success: false,
|
||||
error: 'RATE_LIMIT_EXCEEDED',
|
||||
message: process.env.RATE_LIMIT_MESSAGE || '请求过于频繁,请稍后再试'
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
});
|
||||
this.app.use('/api', limiter);
|
||||
|
||||
// 请求日志中间件
|
||||
this.app.use((req, res, next) => {
|
||||
const startTime = Date.now();
|
||||
this.stats.requestCount++;
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.info('HTTP Request', {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
status: res.statusCode,
|
||||
duration: `${duration}ms`,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent'),
|
||||
contentLength: req.get('Content-Length') || 0
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置路由
|
||||
*/
|
||||
setupRoutes() {
|
||||
// 根路径 - 服务信息
|
||||
this.app.get('/', (req, res) => {
|
||||
res.json({
|
||||
name: 'Push Receiver Service',
|
||||
version: '1.0.0',
|
||||
description: '专门接收推送消息的独立服务',
|
||||
status: 'running',
|
||||
uptime: Math.floor((Date.now() - this.stats.startTime.getTime()) / 1000),
|
||||
stats: {
|
||||
...this.stats,
|
||||
uptime: Math.floor((Date.now() - this.stats.startTime.getTime()) / 1000)
|
||||
},
|
||||
endpoints: {
|
||||
pushMessage: 'POST /api/push/message',
|
||||
pushBatch: 'POST /api/push/batch',
|
||||
health: 'GET /api/health',
|
||||
stats: 'GET /api/stats'
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// 健康检查
|
||||
this.app.get('/api/health', async (req, res) => {
|
||||
try {
|
||||
const dbHealth = await this.db.getHealthStatus();
|
||||
|
||||
res.json({
|
||||
status: 'OK',
|
||||
service: 'Push Receiver Service',
|
||||
version: '1.0.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor((Date.now() - this.stats.startTime.getTime()) / 1000),
|
||||
database: dbHealth.database,
|
||||
stats: this.stats
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('健康检查失败', { error: error.message, stack: error.stack });
|
||||
res.status(500).json({
|
||||
status: 'ERROR',
|
||||
service: 'Push Receiver Service',
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 接收单个推送消息
|
||||
this.app.post('/api/push/message',
|
||||
[
|
||||
body('pushType').notEmpty().withMessage('推送类型不能为空'),
|
||||
body('userId').optional().isString().withMessage('用户ID必须是字符串'),
|
||||
body('deviceId').optional().isString().withMessage('设备ID必须是字符串')
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
// 验证请求数据
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: '请求数据验证失败',
|
||||
details: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
// 准备消息数据
|
||||
const messageData = {
|
||||
...req.body,
|
||||
source_ip: req.ip,
|
||||
user_agent: req.get('User-Agent'),
|
||||
raw_data: req.body,
|
||||
parsed_data: req.body,
|
||||
received_at: new Date()
|
||||
};
|
||||
|
||||
// 保存到数据库
|
||||
const result = await this.db.insertPushMessage(messageData);
|
||||
|
||||
this.stats.messageCount++;
|
||||
|
||||
this.logger.info('推送消息接收成功', {
|
||||
messageId: result.messageId,
|
||||
pushType: req.body.pushType,
|
||||
userId: req.body.userId,
|
||||
deviceId: req.body.deviceId,
|
||||
isDuplicate: result.isDuplicate,
|
||||
processingTime: result.processingTime
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '推送消息接收成功',
|
||||
data: {
|
||||
messageId: result.messageId,
|
||||
isDuplicate: result.isDuplicate,
|
||||
processingTime: result.processingTime,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.stats.errorCount++;
|
||||
this.logger.error('推送消息接收失败', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
requestBody: req.body
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'MESSAGE_PROCESSING_ERROR',
|
||||
message: '推送消息处理失败',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 批量接收推送消息
|
||||
this.app.post('/api/push/batch',
|
||||
[
|
||||
body('messages').isArray().withMessage('messages必须是数组'),
|
||||
body('messages').custom((messages) => {
|
||||
const maxBatchSize = parseInt(process.env.BATCH_SIZE_LIMIT) || 1000;
|
||||
if (messages.length > maxBatchSize) {
|
||||
throw new Error(`批量消息数量不能超过${maxBatchSize}条`);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
// 验证请求数据
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: '请求数据验证失败',
|
||||
details: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { messages } = req.body;
|
||||
|
||||
// 准备批量消息数据
|
||||
const messagesData = messages.map(msg => ({
|
||||
...msg,
|
||||
source_ip: req.ip,
|
||||
user_agent: req.get('User-Agent'),
|
||||
raw_data: msg,
|
||||
parsed_data: msg,
|
||||
received_at: new Date()
|
||||
}));
|
||||
|
||||
// 批量保存到数据库
|
||||
const result = await this.db.insertPushMessagesBatch(messagesData);
|
||||
|
||||
this.stats.messageCount += result.successCount;
|
||||
this.stats.errorCount += result.failureCount;
|
||||
|
||||
this.logger.info('批量推送消息接收完成', {
|
||||
totalCount: result.totalCount,
|
||||
successCount: result.successCount,
|
||||
failureCount: result.failureCount,
|
||||
processingTime: result.processingTime
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `批量推送消息处理完成,成功${result.successCount}条,失败${result.failureCount}条`,
|
||||
data: {
|
||||
totalCount: result.totalCount,
|
||||
successCount: result.successCount,
|
||||
failureCount: result.failureCount,
|
||||
processingTime: result.processingTime,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.stats.errorCount++;
|
||||
this.logger.error('批量推送消息接收失败', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
requestBody: req.body
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'BATCH_PROCESSING_ERROR',
|
||||
message: '批量推送消息处理失败',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 获取统计信息
|
||||
this.app.get('/api/stats', async (req, res) => {
|
||||
try {
|
||||
const hoursBack = parseInt(req.query.hours) || 24;
|
||||
const messageStats = await this.db.getMessageStats(hoursBack);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
service: {
|
||||
uptime: Math.floor((Date.now() - this.stats.startTime.getTime()) / 1000),
|
||||
...this.stats
|
||||
},
|
||||
messages: messageStats,
|
||||
period: `最近${hoursBack}小时`
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('获取统计信息失败', { error: error.message, stack: error.stack });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'STATS_ERROR',
|
||||
message: '获取统计信息失败',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置错误处理
|
||||
*/
|
||||
setupErrorHandling() {
|
||||
// 404 处理
|
||||
this.app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'NOT_FOUND',
|
||||
message: '请求的端点不存在',
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
availableEndpoints: [
|
||||
'GET /',
|
||||
'GET /api/health',
|
||||
'POST /api/push/message',
|
||||
'POST /api/push/batch',
|
||||
'GET /api/stats'
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
// 全局错误处理
|
||||
this.app.use((error, req, res, next) => {
|
||||
this.stats.errorCount++;
|
||||
this.logger.error('未处理的错误', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
url: req.url,
|
||||
method: req.method
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'INTERNAL_SERVER_ERROR',
|
||||
message: '服务器内部错误',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动服务器
|
||||
*/
|
||||
async start() {
|
||||
try {
|
||||
// 测试数据库连接
|
||||
await this.db.testConnection();
|
||||
|
||||
// 启动HTTP服务器
|
||||
this.server = this.app.listen(this.port, this.host, () => {
|
||||
console.log('🚀 推送消息接收服务启动成功!');
|
||||
console.log(`📍 服务器地址: http://${this.host}:${this.port}`);
|
||||
console.log('');
|
||||
console.log('📋 API 端点:');
|
||||
console.log(` - 服务信息: http://${this.host}:${this.port}/`);
|
||||
console.log(` - 健康检查: http://${this.host}:${this.port}/api/health`);
|
||||
console.log(` - 推送消息: http://${this.host}:${this.port}/api/push/message`);
|
||||
console.log(` - 批量推送: http://${this.host}:${this.port}/api/push/batch`);
|
||||
console.log(` - 统计信息: http://${this.host}:${this.port}/api/stats`);
|
||||
console.log('');
|
||||
console.log('🗄️ 数据库连接: ✅ 正常');
|
||||
console.log('🔒 API密钥保护: ' + (process.env.API_KEY ? '✅ 启用' : '❌ 未设置'));
|
||||
console.log('📊 请求速率限制: ✅ 启用');
|
||||
console.log('');
|
||||
console.log('✅ 服务已准备就绪,等待接收推送消息...');
|
||||
console.log('💡 按 Ctrl+C 停止服务');
|
||||
|
||||
this.logger.info('推送消息接收服务启动完成', {
|
||||
port: this.port,
|
||||
host: this.host,
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
apiKeyEnabled: !!process.env.API_KEY
|
||||
});
|
||||
});
|
||||
|
||||
// 设置优雅关闭
|
||||
this.setupGracefulShutdown();
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('服务启动失败', { error: error.message, stack: error.stack });
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置优雅关闭
|
||||
*/
|
||||
setupGracefulShutdown() {
|
||||
const shutdown = async (signal) => {
|
||||
console.log(`\n📴 收到${signal}信号,正在优雅关闭服务...`);
|
||||
this.logger.info(`收到${signal}信号,开始关闭服务`);
|
||||
|
||||
// 停止接收新请求
|
||||
if (this.server) {
|
||||
this.server.close(async () => {
|
||||
console.log('🌐 HTTP服务器已关闭');
|
||||
this.logger.info('HTTP服务器已关闭');
|
||||
|
||||
// 关闭数据库连接
|
||||
try {
|
||||
await this.db.close();
|
||||
console.log('🗄️ 数据库连接已关闭');
|
||||
this.logger.info('数据库连接已关闭');
|
||||
} catch (error) {
|
||||
console.error('❌ 关闭数据库连接失败:', error.message);
|
||||
this.logger.error('关闭数据库连接失败', { error: error.message });
|
||||
}
|
||||
|
||||
console.log('✅ 服务已安全关闭');
|
||||
this.logger.info('服务已安全关闭');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// 设置强制关闭超时
|
||||
setTimeout(() => {
|
||||
console.error('❌ 强制关闭服务(超时)');
|
||||
this.logger.error('强制关闭服务(超时)');
|
||||
process.exit(1);
|
||||
}, 30000);
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
|
||||
// 捕获未处理的异常
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('💥 未捕获的异常:', error);
|
||||
this.logger.error('未捕获的异常', { error: error.message, stack: error.stack });
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('💥 未处理的 Promise 拒绝:', reason);
|
||||
this.logger.error('未处理的 Promise 拒绝', { reason: reason?.message || reason });
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 启动服务
|
||||
if (require.main === module) {
|
||||
const service = new PushReceiverService();
|
||||
service.start();
|
||||
}
|
||||
|
||||
module.exports = PushReceiverService;
|
||||
219
push-receiver-service/setup-database.js
Normal file
219
push-receiver-service/setup-database.js
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 数据库初始化脚本
|
||||
* 自动创建数据库表结构和初始数据
|
||||
*/
|
||||
|
||||
const DatabaseManager = require('./lib/database');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
require('dotenv').config();
|
||||
|
||||
class DatabaseSetup {
|
||||
constructor() {
|
||||
this.db = new DatabaseManager();
|
||||
}
|
||||
|
||||
async setup() {
|
||||
try {
|
||||
console.log('🔧 开始数据库初始化...');
|
||||
|
||||
// 测试数据库连接
|
||||
console.log('📡 测试数据库连接...');
|
||||
await this.db.testConnection();
|
||||
console.log('✅ 数据库连接成功');
|
||||
|
||||
// 读取并执行初始化SQL
|
||||
console.log('📋 执行数据库初始化脚本...');
|
||||
const sqlPath = path.join(__dirname, 'database', 'init.sql');
|
||||
|
||||
if (fs.existsSync(sqlPath)) {
|
||||
const initSQL = fs.readFileSync(sqlPath, 'utf8');
|
||||
|
||||
// 将SQL分割成多个语句执行
|
||||
const statements = initSQL
|
||||
.split(';')
|
||||
.filter(stmt => stmt.trim().length > 0)
|
||||
.map(stmt => stmt.trim() + ';');
|
||||
|
||||
const client = await this.db.pool.connect();
|
||||
|
||||
try {
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
const statement = statements[i];
|
||||
if (statement.trim() === ';') continue;
|
||||
|
||||
try {
|
||||
await client.query(statement);
|
||||
console.log(`✅ 执行语句 ${i + 1}/${statements.length}`);
|
||||
} catch (error) {
|
||||
// 跳过一些可能的错误(如表已存在等)
|
||||
if (error.message.includes('already exists') ||
|
||||
error.message.includes('duplicate key')) {
|
||||
console.log(`⚠️ 跳过语句 ${i + 1}/${statements.length}: ${error.message}`);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
console.log('✅ 数据库初始化脚本执行完成');
|
||||
} else {
|
||||
console.log('❌ 未找到初始化SQL文件:', sqlPath);
|
||||
}
|
||||
|
||||
// 验证表结构
|
||||
console.log('🔍 验证数据库表结构...');
|
||||
await this.verifyTables();
|
||||
|
||||
// 插入测试数据
|
||||
console.log('📝 插入测试数据...');
|
||||
await this.insertTestData();
|
||||
|
||||
console.log('🎉 数据库初始化完成!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 数据库初始化失败:', error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
await this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async verifyTables() {
|
||||
const expectedTables = [
|
||||
'ps_push_messages',
|
||||
'ps_push_types',
|
||||
'message_processing_logs',
|
||||
'devices',
|
||||
'users',
|
||||
'system_stats'
|
||||
];
|
||||
|
||||
const client = await this.db.pool.connect();
|
||||
|
||||
try {
|
||||
const result = await client.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name
|
||||
`);
|
||||
|
||||
const existingTables = result.rows.map(row => row.table_name);
|
||||
|
||||
console.log('📊 现有数据表:');
|
||||
existingTables.forEach(table => {
|
||||
const exists = expectedTables.includes(table);
|
||||
console.log(` ${exists ? '✅' : '❓'} ${table}`);
|
||||
});
|
||||
|
||||
const missingTables = expectedTables.filter(table => !existingTables.includes(table));
|
||||
if (missingTables.length > 0) {
|
||||
console.log('❌ 缺少数据表:', missingTables.join(', '));
|
||||
throw new Error(`缺少必要的数据表: ${missingTables.join(', ')}`);
|
||||
}
|
||||
|
||||
console.log('✅ 所有必要数据表已存在');
|
||||
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async insertTestData() {
|
||||
const client = await this.db.pool.connect();
|
||||
|
||||
try {
|
||||
// 插入测试推送消息
|
||||
const testMessages = [
|
||||
{
|
||||
push_type: 'HEALTH',
|
||||
user_id: 'test_user_001',
|
||||
device_id: 'device_001',
|
||||
raw_data: {
|
||||
pushType: 'HEALTH',
|
||||
userId: 'test_user_001',
|
||||
H: 75,
|
||||
O: 98,
|
||||
T: 36.5
|
||||
}
|
||||
},
|
||||
{
|
||||
push_type: 'SOS',
|
||||
user_id: 'test_user_002',
|
||||
device_id: 'device_002',
|
||||
raw_data: {
|
||||
pushType: 'SOS',
|
||||
userId: 'test_user_002',
|
||||
emergencyLevel: 'HIGH',
|
||||
location: {
|
||||
lat: 39.9042,
|
||||
lng: 116.4074
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
push_type: 'LOCATION',
|
||||
user_id: 'test_user_003',
|
||||
device_id: 'device_003',
|
||||
raw_data: {
|
||||
pushType: 'LOCATION',
|
||||
userId: 'test_user_003',
|
||||
lat: 31.2304,
|
||||
lng: 121.4737,
|
||||
accuracy: 10
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
for (const message of testMessages) {
|
||||
const checksum = require('crypto')
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(message.raw_data))
|
||||
.digest('hex');
|
||||
|
||||
await client.query(`
|
||||
INSERT INTO ps_push_messages (
|
||||
push_type, user_id, device_id, raw_data, parsed_data,
|
||||
checksum, priority, processing_status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (checksum) DO NOTHING
|
||||
`, [
|
||||
message.push_type,
|
||||
message.user_id,
|
||||
message.device_id,
|
||||
JSON.stringify(message.raw_data),
|
||||
JSON.stringify(message.raw_data),
|
||||
checksum,
|
||||
message.push_type === 'SOS' ? 1 : 5,
|
||||
'processed'
|
||||
]);
|
||||
}
|
||||
|
||||
console.log('✅ 测试数据插入完成');
|
||||
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果直接运行此脚本
|
||||
if (require.main === module) {
|
||||
const setup = new DatabaseSetup();
|
||||
setup.setup()
|
||||
.then(() => {
|
||||
console.log('🎉 数据库设置完成,可以启动服务了!');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('❌ 数据库设置失败:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = DatabaseSetup;
|
||||
249
push-receiver-service/setup-supabase.js
Normal file
249
push-receiver-service/setup-supabase.js
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Supabase 数据库初始化脚本
|
||||
* 自动检查并初始化 Supabase 数据库表结构
|
||||
*/
|
||||
|
||||
const SupabaseDatabaseManager = require('./lib/supabase-database');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
require('dotenv').config();
|
||||
|
||||
class SupabaseDatabaseSetup {
|
||||
constructor() {
|
||||
this.db = new SupabaseDatabaseManager();
|
||||
}
|
||||
|
||||
async setup() {
|
||||
try {
|
||||
console.log('🔧 开始 Supabase 数据库初始化...');
|
||||
|
||||
// 测试 Supabase 连接
|
||||
console.log('📡 测试 Supabase 数据库连接...');
|
||||
await this.db.testConnection();
|
||||
console.log('✅ Supabase 数据库连接成功');
|
||||
|
||||
// 检查环境变量
|
||||
console.log('🔍 检查环境配置...');
|
||||
this.checkEnvironmentVariables();
|
||||
|
||||
// 验证表结构
|
||||
console.log('🔍 验证数据库表结构...');
|
||||
await this.verifyTables();
|
||||
|
||||
// 插入测试数据
|
||||
console.log('📝 插入测试数据...');
|
||||
await this.insertTestData();
|
||||
|
||||
// 测试基本功能
|
||||
console.log('🧪 测试基本功能...');
|
||||
await this.testBasicFunctionality();
|
||||
|
||||
console.log('🎉 Supabase 数据库初始化完成!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Supabase 数据库初始化失败:', error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
await this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
checkEnvironmentVariables() {
|
||||
const requiredEnvVars = [
|
||||
'SUPABASE_URL',
|
||||
'SUPABASE_SERVICE_ROLE_KEY'
|
||||
];
|
||||
|
||||
const missingVars = requiredEnvVars.filter(varName => !process.env[varName]);
|
||||
|
||||
if (missingVars.length > 0) {
|
||||
throw new Error(`缺少必要的环境变量: ${missingVars.join(', ')}`);
|
||||
}
|
||||
|
||||
console.log('✅ 环境配置检查通过');
|
||||
console.log(` - Supabase URL: ${process.env.SUPABASE_URL}`);
|
||||
console.log(` - Service Role Key: ${'*'.repeat(20)}${process.env.SUPABASE_SERVICE_ROLE_KEY.slice(-10)}`);
|
||||
}
|
||||
|
||||
async verifyTables() {
|
||||
const expectedTables = [
|
||||
'ps_push_messages',
|
||||
'ps_push_types',
|
||||
'message_processing_logs',
|
||||
'devices',
|
||||
'users',
|
||||
'system_stats'
|
||||
];
|
||||
|
||||
try {
|
||||
// 检查每个表是否存在
|
||||
for (const tableName of expectedTables) {
|
||||
const { data, error } = await this.db.supabase
|
||||
.from(tableName)
|
||||
.select('*')
|
||||
.limit(1);
|
||||
|
||||
if (error) {
|
||||
console.log(`❌ 数据表 ${tableName} 不存在或无法访问`);
|
||||
console.log(` 错误: ${error.message}`);
|
||||
console.log(` 📋 请在 Supabase Dashboard 的 SQL Editor 中执行 database/supabase-init.sql`);
|
||||
throw new Error(`数据表 ${tableName} 不可用: ${error.message}`);
|
||||
} else {
|
||||
console.log(`✅ 数据表 ${tableName} 可用`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ 所有必要数据表验证通过');
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`数据表验证失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async insertTestData() {
|
||||
try {
|
||||
// 检查是否已有测试数据
|
||||
const { data: existingData } = await this.db.supabase
|
||||
.from('ps_push_messages')
|
||||
.select('id')
|
||||
.eq('user_id', 'test_user_supabase')
|
||||
.limit(1);
|
||||
|
||||
if (existingData && existingData.length > 0) {
|
||||
console.log('ℹ️ 测试数据已存在,跳过插入');
|
||||
return;
|
||||
}
|
||||
|
||||
// 插入测试推送消息
|
||||
const testMessages = [
|
||||
{
|
||||
pushType: 'HEALTH',
|
||||
userId: 'test_user_supabase',
|
||||
deviceId: 'device_supabase_001',
|
||||
H: 75,
|
||||
O: 98,
|
||||
T: 36.5,
|
||||
source_ip: '127.0.0.1',
|
||||
user_agent: 'Supabase Test Client'
|
||||
},
|
||||
{
|
||||
pushType: 'SOS',
|
||||
userId: 'test_user_supabase_002',
|
||||
deviceId: 'device_supabase_002',
|
||||
emergencyLevel: 'HIGH',
|
||||
lat: 39.9042,
|
||||
lng: 116.4074,
|
||||
source_ip: '127.0.0.1',
|
||||
user_agent: 'Supabase Test Client'
|
||||
},
|
||||
{
|
||||
pushType: 'LOCATION',
|
||||
userId: 'test_user_supabase_003',
|
||||
deviceId: 'device_supabase_003',
|
||||
lat: 31.2304,
|
||||
lng: 121.4737,
|
||||
accuracy: 10,
|
||||
source_ip: '127.0.0.1',
|
||||
user_agent: 'Supabase Test Client'
|
||||
}
|
||||
];
|
||||
|
||||
for (const message of testMessages) {
|
||||
await this.db.insertPushMessage(message);
|
||||
}
|
||||
|
||||
console.log('✅ 测试数据插入完成');
|
||||
|
||||
} catch (error) {
|
||||
console.warn('⚠️ 测试数据插入失败:', error.message);
|
||||
// 不阻止初始化过程
|
||||
}
|
||||
}
|
||||
|
||||
async testBasicFunctionality() {
|
||||
try {
|
||||
// 测试插入消息
|
||||
const testMessage = {
|
||||
pushType: 'TEST',
|
||||
userId: 'setup_test_user',
|
||||
deviceId: 'setup_test_device',
|
||||
testData: 'Supabase setup test',
|
||||
source_ip: '127.0.0.1',
|
||||
user_agent: 'Setup Test Client'
|
||||
};
|
||||
|
||||
const insertResult = await this.db.insertPushMessage(testMessage);
|
||||
console.log('✅ 消息插入测试通过');
|
||||
|
||||
// 测试获取统计信息
|
||||
const stats = await this.db.getMessageStats(1);
|
||||
console.log('✅ 统计信息获取测试通过');
|
||||
|
||||
// 测试获取健康状态
|
||||
const health = await this.db.getHealthStatus();
|
||||
console.log('✅ 健康状态检查测试通过');
|
||||
|
||||
// 测试获取消息列表
|
||||
const messages = await this.db.getPushMessages({ limit: 5 });
|
||||
console.log('✅ 消息列表获取测试通过');
|
||||
|
||||
console.log('✅ 所有基本功能测试通过');
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`基本功能测试失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async showSetupInstructions() {
|
||||
console.log('\n📋 Supabase 数据库设置说明:');
|
||||
console.log('');
|
||||
console.log('1. 创建 Supabase 项目:');
|
||||
console.log(' - 访问 https://supabase.com/dashboard');
|
||||
console.log(' - 点击 "New project" 创建新项目');
|
||||
console.log(' - 记录项目的 URL 和 API Keys');
|
||||
console.log('');
|
||||
console.log('2. 执行数据库初始化脚本:');
|
||||
console.log(' - 在 Supabase Dashboard 中打开 "SQL Editor"');
|
||||
console.log(' - 复制 database/supabase-init.sql 文件内容');
|
||||
console.log(' - 粘贴到 SQL Editor 中并执行');
|
||||
console.log('');
|
||||
console.log('3. 配置环境变量:');
|
||||
console.log(' - 复制 .env.supabase 为 .env');
|
||||
console.log(' - 更新 SUPABASE_URL 为您的项目 URL');
|
||||
console.log(' - 更新 SUPABASE_SERVICE_ROLE_KEY 为您的 Service Role Key');
|
||||
console.log('');
|
||||
console.log('4. 启用实时功能 (可选):');
|
||||
console.log(' - 在 Database > Replication 中');
|
||||
console.log(' - 启用 push_messages 表的实时复制');
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
// 如果直接运行此脚本
|
||||
if (require.main === module) {
|
||||
const setup = new SupabaseDatabaseSetup();
|
||||
|
||||
// 检查是否有必要的环境变量
|
||||
if (!process.env.SUPABASE_URL || !process.env.SUPABASE_SERVICE_ROLE_KEY) {
|
||||
console.log('⚠️ 缺少 Supabase 环境配置');
|
||||
setup.showSetupInstructions();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
setup.setup()
|
||||
.then(() => {
|
||||
console.log('🎉 Supabase 数据库设置完成,可以启动服务了!');
|
||||
console.log('');
|
||||
console.log('🚀 启动服务命令:');
|
||||
console.log(' node supabase-server.js');
|
||||
console.log('');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('❌ Supabase 数据库设置失败:', error.message);
|
||||
setup.showSetupInstructions();
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = SupabaseDatabaseSetup;
|
||||
76
push-receiver-service/start.bat
Normal file
76
push-receiver-service/start.bat
Normal file
@@ -0,0 +1,76 @@
|
||||
@echo off
|
||||
REM 推送消息接收服务启动脚本 (Windows)
|
||||
|
||||
echo 🚀 启动推送消息接收服务...
|
||||
|
||||
REM 检查 Node.js 是否安装
|
||||
node --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo ❌ Node.js 未安装,请先安装 Node.js
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM 检查是否在正确的目录
|
||||
if not exist "package.json" (
|
||||
echo ❌ 请在 push-receiver-service 目录中运行此脚本
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM 安装依赖
|
||||
echo 📦 安装依赖包...
|
||||
npm install
|
||||
if %errorlevel% neq 0 (
|
||||
echo ❌ 依赖安装失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM 检查环境配置
|
||||
if not exist ".env" (
|
||||
echo ⚠️ 未找到 .env 文件,复制模板...
|
||||
if exist ".env.supabase" (
|
||||
copy .env.supabase .env
|
||||
echo 📋 已复制 .env.supabase 为 .env,请编辑配置
|
||||
echo - SUPABASE_URL: 您的 Supabase 项目 URL
|
||||
echo - SUPABASE_SERVICE_ROLE_KEY: 您的 Service Role Key
|
||||
echo - API_KEY: 自定义 API 密钥
|
||||
set /p confirm="是否已配置完成环境变量?(y/n): "
|
||||
if /i not "%confirm%"=="y" (
|
||||
echo ❌ 请先配置环境变量再启动服务
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
) else (
|
||||
echo ❌ 未找到环境配置模板文件
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
REM 初始化数据库
|
||||
echo 🗄️ 初始化 Supabase 数据库...
|
||||
node setup-supabase.js
|
||||
if %errorlevel% neq 0 (
|
||||
echo ❌ 数据库初始化失败,请检查配置
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM 运行测试
|
||||
echo 🧪 运行基础测试...
|
||||
node test.js
|
||||
if %errorlevel% neq 0 (
|
||||
echo ⚠️ 测试有错误,但继续启动服务...
|
||||
)
|
||||
|
||||
REM 启动服务
|
||||
echo 🚀 启动推送消息接收服务...
|
||||
echo 📍 服务将在 http://localhost:3001 启动
|
||||
echo 💡 按 Ctrl+C 停止服务
|
||||
echo.
|
||||
|
||||
node supabase-server.js
|
||||
|
||||
pause
|
||||
67
push-receiver-service/start.sh
Normal file
67
push-receiver-service/start.sh
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 推送消息接收服务启动脚本
|
||||
|
||||
echo "🚀 启动推送消息接收服务..."
|
||||
|
||||
# 检查 Node.js 是否安装
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "❌ Node.js 未安装,请先安装 Node.js"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查是否在正确的目录
|
||||
if [ ! -f "package.json" ]; then
|
||||
echo "❌ 请在 push-receiver-service 目录中运行此脚本"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 安装依赖
|
||||
echo "📦 安装依赖包..."
|
||||
npm install
|
||||
|
||||
# 检查环境配置
|
||||
if [ ! -f ".env" ]; then
|
||||
echo "⚠️ 未找到 .env 文件,复制模板..."
|
||||
if [ -f ".env.supabase" ]; then
|
||||
cp .env.supabase .env
|
||||
echo "📋 已复制 .env.supabase 为 .env,请编辑配置"
|
||||
echo " - SUPABASE_URL: 您的 Supabase 项目 URL"
|
||||
echo " - SUPABASE_SERVICE_ROLE_KEY: 您的 Service Role Key"
|
||||
echo " - API_KEY: 自定义 API 密钥"
|
||||
read -p "是否已配置完成环境变量?(y/n): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "❌ 请先配置环境变量再启动服务"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ 未找到环境配置模板文件"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 初始化数据库
|
||||
echo "🗄️ 初始化 Supabase 数据库..."
|
||||
node setup-supabase.js
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ 数据库初始化失败,请检查配置"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 运行测试
|
||||
echo "🧪 运行基础测试..."
|
||||
node test.js
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "⚠️ 测试有错误,但继续启动服务..."
|
||||
fi
|
||||
|
||||
# 启动服务
|
||||
echo "🚀 启动推送消息接收服务..."
|
||||
echo "📍 服务将在 http://localhost:3001 启动"
|
||||
echo "💡 按 Ctrl+C 停止服务"
|
||||
echo ""
|
||||
|
||||
node supabase-server.js
|
||||
661
push-receiver-service/supabase-server.js
Normal file
661
push-receiver-service/supabase-server.js
Normal file
@@ -0,0 +1,661 @@
|
||||
/**
|
||||
* 基于 Supabase 的推送消息接收服务主服务器
|
||||
* 专门用于接收和存储各种推送消息到 Supabase 数据库
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const compression = require('compression');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const winston = require('winston');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
require('dotenv').config();
|
||||
|
||||
const SupabaseDatabaseManager = require('./lib/supabase-database');
|
||||
|
||||
class SupabasePushReceiverService {
|
||||
constructor() {
|
||||
this.app = express();
|
||||
this.port = process.env.PORT || 3001;
|
||||
this.host = process.env.HOST || '0.0.0.0';
|
||||
this.db = new SupabaseDatabaseManager();
|
||||
|
||||
this.setupLogger();
|
||||
this.setupMiddleware();
|
||||
this.setupRoutes();
|
||||
this.setupErrorHandling();
|
||||
|
||||
// 统计信息
|
||||
this.stats = {
|
||||
startTime: new Date(),
|
||||
requestCount: 0,
|
||||
messageCount: 0,
|
||||
errorCount: 0,
|
||||
lastMessageTime: null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置日志系统
|
||||
*/
|
||||
setupLogger() {
|
||||
// 确保日志目录存在
|
||||
const logDir = process.env.LOG_DIR || './logs';
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
|
||||
this.logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'supabase-push-service-error.log'),
|
||||
level: 'error',
|
||||
maxsize: parseInt(process.env.LOG_FILE_MAX_SIZE) || 10485760,
|
||||
maxFiles: parseInt(process.env.LOG_FILE_MAX_FILES) || 5
|
||||
}),
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'supabase-push-service.log'),
|
||||
maxsize: parseInt(process.env.LOG_FILE_MAX_SIZE) || 10485760,
|
||||
maxFiles: parseInt(process.env.LOG_FILE_MAX_FILES) || 5
|
||||
}),
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
)
|
||||
})
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置中间件
|
||||
*/
|
||||
setupMiddleware() {
|
||||
// 安全中间件
|
||||
this.app.use(helmet({
|
||||
contentSecurityPolicy: false
|
||||
}));
|
||||
|
||||
// 压缩中间件
|
||||
this.app.use(compression());
|
||||
|
||||
// CORS 配置
|
||||
const corsOptions = {
|
||||
origin: (origin, callback) => {
|
||||
const allowedOrigins = process.env.ALLOWED_ORIGINS;
|
||||
if (!allowedOrigins || allowedOrigins === '*') {
|
||||
callback(null, true);
|
||||
} else {
|
||||
const origins = allowedOrigins.split(',');
|
||||
if (origins.includes(origin) || !origin) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error('不允许的CORS来源'));
|
||||
}
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
optionsSuccessStatus: 200
|
||||
};
|
||||
this.app.use(cors(corsOptions));
|
||||
|
||||
// 请求体解析
|
||||
this.app.use(express.json({
|
||||
limit: process.env.MAX_MESSAGE_SIZE || '1mb'
|
||||
}));
|
||||
this.app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// API密钥验证中间件
|
||||
this.app.use('/api', (req, res, next) => {
|
||||
if (req.path === '/health') {
|
||||
return next(); // 健康检查不需要验证
|
||||
}
|
||||
|
||||
// const apiKey = req.headers['x-api-key'] || req.headers['authorization'];
|
||||
// const expectedApiKey = process.env.API_KEY;
|
||||
|
||||
// if (expectedApiKey && (!apiKey || apiKey !== expectedApiKey)) {
|
||||
// this.stats.errorCount++;
|
||||
// return res.status(401).json({
|
||||
// success: false,
|
||||
// error: 'UNAUTHORIZED',
|
||||
// message: 'API密钥无效或缺失'
|
||||
// });
|
||||
// }
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// 速率限制
|
||||
const limiter = rateLimit({
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 60000,
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 1000,
|
||||
message: {
|
||||
success: false,
|
||||
error: 'RATE_LIMIT_EXCEEDED',
|
||||
message: process.env.RATE_LIMIT_MESSAGE || '请求过于频繁,请稍后再试'
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
});
|
||||
this.app.use('/api', limiter);
|
||||
|
||||
// 请求日志中间件
|
||||
this.app.use((req, res, next) => {
|
||||
const startTime = Date.now();
|
||||
this.stats.requestCount++;
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.info('HTTP Request', {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
status: res.statusCode,
|
||||
duration: `${duration}ms`,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent'),
|
||||
contentLength: req.get('Content-Length') || 0
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置路由
|
||||
*/
|
||||
setupRoutes() {
|
||||
// 根路径 - 服务信息
|
||||
this.app.get('/', (req, res) => {
|
||||
res.json({
|
||||
name: 'Supabase Push Receiver Service',
|
||||
version: '1.0.0',
|
||||
description: '基于 Supabase 的推送消息接收服务',
|
||||
status: 'running',
|
||||
database: 'Supabase PostgreSQL',
|
||||
uptime: Math.floor((Date.now() - this.stats.startTime.getTime()) / 1000),
|
||||
stats: {
|
||||
...this.stats,
|
||||
uptime: Math.floor((Date.now() - this.stats.startTime.getTime()) / 1000)
|
||||
},
|
||||
endpoints: {
|
||||
pushMessage: 'POST /api/push/message',
|
||||
pushBatch: 'POST /api/push/batch',
|
||||
health: 'GET /api/health',
|
||||
stats: 'GET /api/stats',
|
||||
messages: 'GET /api/messages'
|
||||
},
|
||||
supabase: {
|
||||
url: process.env.SUPABASE_URL,
|
||||
hasServiceKey: !!process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// 健康检查
|
||||
this.app.get('/api/health', async (req, res) => {
|
||||
try {
|
||||
const dbHealth = await this.db.getHealthStatus();
|
||||
|
||||
res.json({
|
||||
status: 'OK',
|
||||
service: 'Supabase Push Receiver Service',
|
||||
version: '1.0.0',
|
||||
database: 'Supabase PostgreSQL',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor((Date.now() - this.stats.startTime.getTime()) / 1000),
|
||||
supabase: dbHealth.database,
|
||||
stats: this.stats
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('健康检查失败', { error: error.message, stack: error.stack });
|
||||
res.status(500).json({
|
||||
status: 'ERROR',
|
||||
service: 'Supabase Push Receiver Service',
|
||||
database: 'Supabase PostgreSQL',
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 接收单个推送消息
|
||||
this.app.post('/api/push/message',
|
||||
[
|
||||
body('pushType').notEmpty().withMessage('推送类型不能为空'),
|
||||
body('MID').optional().isString().withMessage('设备ID必须是字符串')
|
||||
],
|
||||
async (req, res) => {
|
||||
console.log('📥 接收到推送消息:', req.body);
|
||||
try {
|
||||
// 验证请求数据
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: '请求数据验证失败',
|
||||
details: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
// 准备消息数据
|
||||
const messageData = {
|
||||
message_id: req.body.aaa || null,
|
||||
user_id: req.body.user_id || null,
|
||||
device_id: req.body.device_id || null,
|
||||
push_type: req.body.pushType,
|
||||
source_ip: req.ip,
|
||||
user_agent: req.get('User-Agent'),
|
||||
raw_data: req.body,
|
||||
parsed_data: req.body,
|
||||
received_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
// 保存到 Supabase
|
||||
const result = await this.db.insertPushMessage(messageData);
|
||||
|
||||
this.stats.messageCount++;
|
||||
this.stats.lastMessageTime = new Date();
|
||||
|
||||
this.logger.info('推送消息接收成功', {
|
||||
messageId: result.messageId,
|
||||
pushType: req.body.pushType,
|
||||
userId: req.body.userId,
|
||||
deviceId: req.body.deviceId,
|
||||
isDuplicate: result.isDuplicate,
|
||||
processingTime: result.processingTime
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '推送消息接收成功',
|
||||
data: {
|
||||
messageId: result.messageId,
|
||||
isDuplicate: result.isDuplicate,
|
||||
processingTime: result.processingTime,
|
||||
timestamp: new Date().toISOString(),
|
||||
database: 'Supabase'
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.stats.errorCount++;
|
||||
this.logger.error('推送消息接收失败', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
requestBody: req.body
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'MESSAGE_PROCESSING_ERROR',
|
||||
message: '推送消息处理失败',
|
||||
details: error.message,
|
||||
database: 'Supabase'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 批量接收推送消息
|
||||
this.app.post('/api/push/batch',
|
||||
[
|
||||
body('messages').isArray().withMessage('messages必须是数组'),
|
||||
body('messages').custom((messages) => {
|
||||
const maxBatchSize = parseInt(process.env.BATCH_SIZE_LIMIT) || 1000;
|
||||
if (messages.length > maxBatchSize) {
|
||||
throw new Error(`批量消息数量不能超过${maxBatchSize}条`);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
// 验证请求数据
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: '请求数据验证失败',
|
||||
details: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { messages } = req.body;
|
||||
|
||||
// 准备批量消息数据
|
||||
const messagesData = messages.map(msg => ({
|
||||
...msg,
|
||||
source_ip: req.ip,
|
||||
user_agent: req.get('User-Agent'),
|
||||
raw_data: msg,
|
||||
parsed_data: msg,
|
||||
received_at: new Date().toISOString()
|
||||
}));
|
||||
|
||||
// 批量保存到 Supabase
|
||||
const result = await this.db.insertPushMessagesBatch(messagesData);
|
||||
|
||||
this.stats.messageCount += result.successCount;
|
||||
this.stats.errorCount += result.failureCount;
|
||||
this.stats.lastMessageTime = new Date();
|
||||
|
||||
this.logger.info('批量推送消息接收完成', {
|
||||
totalCount: result.totalCount,
|
||||
successCount: result.successCount,
|
||||
failureCount: result.failureCount,
|
||||
processingTime: result.processingTime
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `批量推送消息处理完成,成功${result.successCount}条,失败${result.failureCount}条`,
|
||||
data: {
|
||||
totalCount: result.totalCount,
|
||||
successCount: result.successCount,
|
||||
failureCount: result.failureCount,
|
||||
processingTime: result.processingTime,
|
||||
timestamp: new Date().toISOString(),
|
||||
database: 'Supabase'
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.stats.errorCount++;
|
||||
this.logger.error('批量推送消息接收失败', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
requestBody: req.body
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'BATCH_PROCESSING_ERROR',
|
||||
message: '批量推送消息处理失败',
|
||||
details: error.message,
|
||||
database: 'Supabase'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 获取统计信息
|
||||
this.app.get('/api/stats', async (req, res) => {
|
||||
try {
|
||||
const hoursBack = parseInt(req.query.hours) || 24;
|
||||
const messageStats = await this.db.getMessageStats(hoursBack);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
service: {
|
||||
uptime: Math.floor((Date.now() - this.stats.startTime.getTime()) / 1000),
|
||||
database: 'Supabase PostgreSQL',
|
||||
...this.stats
|
||||
},
|
||||
messages: messageStats,
|
||||
period: `最近${hoursBack}小时`
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('获取统计信息失败', { error: error.message, stack: error.stack });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'STATS_ERROR',
|
||||
message: '获取统计信息失败',
|
||||
details: error.message,
|
||||
database: 'Supabase'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 获取消息列表
|
||||
this.app.get('/api/messages', async (req, res) => {
|
||||
try {
|
||||
const options = {
|
||||
limit: parseInt(req.query.limit) || 50,
|
||||
offset: parseInt(req.query.offset) || 0,
|
||||
pushType: req.query.pushType || null,
|
||||
userId: req.query.userId || null,
|
||||
startDate: req.query.startDate || null,
|
||||
endDate: req.query.endDate || null,
|
||||
status: req.query.status || null
|
||||
};
|
||||
|
||||
const messages = await this.db.getPushMessages(options);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
messages,
|
||||
count: messages.length,
|
||||
options,
|
||||
database: 'Supabase'
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('获取消息列表失败', { error: error.message, stack: error.stack });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'MESSAGES_ERROR',
|
||||
message: '获取消息列表失败',
|
||||
details: error.message,
|
||||
database: 'Supabase'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 清理旧数据
|
||||
this.app.post('/api/cleanup', async (req, res) => {
|
||||
try {
|
||||
const daysToKeep = parseInt(req.body.days) || 30;
|
||||
const deletedCount = await this.db.cleanupOldMessages(daysToKeep);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `清理完成,删除了${deletedCount}条旧消息`,
|
||||
data: {
|
||||
deletedCount,
|
||||
daysToKeep,
|
||||
database: 'Supabase'
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('清理旧数据失败', { error: error.message, stack: error.stack });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'CLEANUP_ERROR',
|
||||
message: '清理旧数据失败',
|
||||
details: error.message,
|
||||
database: 'Supabase'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置错误处理
|
||||
*/
|
||||
setupErrorHandling() {
|
||||
// 404 处理
|
||||
this.app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'NOT_FOUND',
|
||||
message: '请求的端点不存在',
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
availableEndpoints: [
|
||||
'GET /',
|
||||
'GET /api/health',
|
||||
'POST /api/push/message',
|
||||
'POST /api/push/batch',
|
||||
'GET /api/stats',
|
||||
'GET /api/messages',
|
||||
'POST /api/cleanup'
|
||||
],
|
||||
database: 'Supabase'
|
||||
});
|
||||
});
|
||||
|
||||
// 全局错误处理
|
||||
this.app.use((error, req, res, next) => {
|
||||
this.stats.errorCount++;
|
||||
this.logger.error('未处理的错误', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
url: req.url,
|
||||
method: req.method
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'INTERNAL_SERVER_ERROR',
|
||||
message: '服务器内部错误',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined,
|
||||
database: 'Supabase'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动服务器
|
||||
*/
|
||||
async start() {
|
||||
try {
|
||||
// 测试 Supabase 连接
|
||||
console.log('🔧 测试 Supabase 数据库连接...');
|
||||
await this.db.testConnection();
|
||||
console.log('✅ Supabase 数据库连接成功');
|
||||
|
||||
// 启动HTTP服务器
|
||||
this.server = this.app.listen(this.port, this.host, () => {
|
||||
console.log('🚀 Supabase 推送消息接收服务启动成功!');
|
||||
console.log(`📍 服务器地址: http://${this.host}:${this.port}`);
|
||||
console.log('');
|
||||
console.log('📋 API 端点:');
|
||||
console.log(` - 服务信息: http://${this.host}:${this.port}/`);
|
||||
console.log(` - 健康检查: http://${this.host}:${this.port}/api/health`);
|
||||
console.log(` - 推送消息: http://${this.host}:${this.port}/api/push/message`);
|
||||
console.log(` - 批量推送: http://${this.host}:${this.port}/api/push/batch`);
|
||||
console.log(` - 统计信息: http://${this.host}:${this.port}/api/stats`);
|
||||
console.log(` - 消息列表: http://${this.host}:${this.port}/api/messages`);
|
||||
console.log(` - 清理数据: http://${this.host}:${this.port}/api/cleanup`);
|
||||
console.log('');
|
||||
console.log('🗄️ 数据库: ✅ Supabase PostgreSQL');
|
||||
console.log('🔗 Supabase URL:', process.env.SUPABASE_URL);
|
||||
console.log('🔒 API密钥保护: ' + (process.env.API_KEY ? '✅ 启用' : '❌ 未设置'));
|
||||
console.log('📊 请求速率限制: ✅ 启用');
|
||||
console.log('');
|
||||
console.log('✅ 服务已准备就绪,等待接收推送消息...');
|
||||
console.log('💡 按 Ctrl+C 停止服务');
|
||||
|
||||
this.logger.info('Supabase 推送消息接收服务启动完成', {
|
||||
port: this.port,
|
||||
host: this.host,
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
apiKeyEnabled: !!process.env.API_KEY,
|
||||
supabaseUrl: process.env.SUPABASE_URL
|
||||
});
|
||||
});
|
||||
|
||||
// 监听 server 的 error 事件
|
||||
this.server.on('error', (err) => {
|
||||
this.logger.error('HTTP服务器启动失败', { error: err.message, stack: err.stack });
|
||||
console.error('❌ HTTP服务器启动失败:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// 设置优雅关闭
|
||||
this.setupGracefulShutdown();
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('服务启动失败', { error: error.message, stack: error.stack });
|
||||
console.error('❌ 服务启动失败:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置优雅关闭
|
||||
*/
|
||||
setupGracefulShutdown() {
|
||||
const shutdown = async (signal) => {
|
||||
console.log(`\n📴 收到${signal}信号,正在优雅关闭服务...`);
|
||||
this.logger.info(`收到${signal}信号,开始关闭服务`);
|
||||
|
||||
// 停止接收新请求
|
||||
if (this.server) {
|
||||
this.server.close(async () => {
|
||||
console.log('🌐 HTTP服务器已关闭');
|
||||
this.logger.info('HTTP服务器已关闭');
|
||||
|
||||
// 关闭数据库连接
|
||||
try {
|
||||
await this.db.close();
|
||||
console.log('🗄️ Supabase 连接已关闭');
|
||||
this.logger.info('Supabase 连接已关闭');
|
||||
} catch (error) {
|
||||
console.error('❌ 关闭 Supabase 连接失败:', error.message);
|
||||
this.logger.error('关闭 Supabase 连接失败', { error: error.message });
|
||||
}
|
||||
|
||||
console.log('✅ 服务已安全关闭');
|
||||
this.logger.info('服务已安全关闭');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// 设置强制关闭超时
|
||||
setTimeout(() => {
|
||||
console.error('❌ 强制关闭服务(超时)');
|
||||
this.logger.error('强制关闭服务(超时)');
|
||||
process.exit(1);
|
||||
}, 30000);
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
|
||||
// 捕获未处理的异常
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('💥 未捕获的异常:', error);
|
||||
this.logger.error('未捕获的异常', { error: error.message, stack: error.stack });
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('💥 未处理的 Promise 拒绝:', reason);
|
||||
this.logger.error('未处理的 Promise 拒绝', { reason: reason?.message || reason });
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 启动服务
|
||||
if (require.main === module) {
|
||||
const service = new SupabasePushReceiverService();
|
||||
service.start();
|
||||
}
|
||||
|
||||
module.exports = SupabasePushReceiverService;
|
||||
154
push-receiver-service/test.js
Normal file
154
push-receiver-service/test.js
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 推送消息接收服务测试脚本
|
||||
*/
|
||||
|
||||
const SupabaseDatabaseManager = require('./lib/supabase-database');
|
||||
require('dotenv').config();
|
||||
|
||||
async function testService() {
|
||||
console.log('🧪 开始测试推送消息接收服务...');
|
||||
|
||||
const db = new SupabaseDatabaseManager();
|
||||
|
||||
try {
|
||||
// 测试 1: 数据库连接
|
||||
console.log('\n📡 测试 1: 数据库连接');
|
||||
await db.testConnection();
|
||||
console.log('✅ 数据库连接测试通过');
|
||||
|
||||
// 测试 2: 插入健康数据消息
|
||||
console.log('\n💊 测试 2: 健康数据消息');
|
||||
const healthMessage = {
|
||||
pushType: 'HEALTH',
|
||||
userId: 'test_user_001',
|
||||
deviceId: 'test_device_001',
|
||||
H: 75,
|
||||
O: 98,
|
||||
T: 36.5,
|
||||
source_ip: '127.0.0.1',
|
||||
user_agent: 'Test Client'
|
||||
};
|
||||
|
||||
const healthResult = await db.insertPushMessage(healthMessage);
|
||||
console.log('✅ 健康数据消息插入成功:', healthResult.messageId);
|
||||
|
||||
// 测试 3: 插入 SOS 消息
|
||||
console.log('\n🆘 测试 3: SOS 紧急消息');
|
||||
const sosMessage = {
|
||||
pushType: 'SOS',
|
||||
userId: 'test_user_002',
|
||||
deviceId: 'test_device_002',
|
||||
emergencyLevel: 'HIGH',
|
||||
lat: 39.9042,
|
||||
lng: 116.4074,
|
||||
source_ip: '127.0.0.1',
|
||||
user_agent: 'Emergency Device'
|
||||
};
|
||||
|
||||
const sosResult = await db.insertPushMessage(sosMessage);
|
||||
console.log('✅ SOS 消息插入成功:', sosResult.messageId);
|
||||
|
||||
// 测试 4: 批量插入消息
|
||||
console.log('\n📦 测试 4: 批量消息插入');
|
||||
const batchMessages = [
|
||||
{
|
||||
pushType: 'LOCATION',
|
||||
userId: 'test_user_003',
|
||||
deviceId: 'test_device_003',
|
||||
lat: 31.2304,
|
||||
lng: 121.4737,
|
||||
accuracy: 10,
|
||||
source_ip: '127.0.0.1',
|
||||
user_agent: 'GPS Device'
|
||||
},
|
||||
{
|
||||
pushType: 'DEVICE_STATUS',
|
||||
userId: 'test_user_004',
|
||||
deviceId: 'test_device_004',
|
||||
status: 'online',
|
||||
batteryLevel: 85,
|
||||
source_ip: '127.0.0.1',
|
||||
user_agent: 'IoT Device'
|
||||
},
|
||||
{
|
||||
pushType: 'ACTIVITY',
|
||||
userId: 'test_user_005',
|
||||
deviceId: 'test_device_005',
|
||||
activityType: 'running',
|
||||
duration: 1800,
|
||||
calories: 250,
|
||||
source_ip: '127.0.0.1',
|
||||
user_agent: 'Fitness Tracker'
|
||||
}
|
||||
];
|
||||
|
||||
const batchResult = await db.insertPushMessagesBatch(batchMessages);
|
||||
console.log('✅ 批量消息插入成功:', `${batchResult.successCount}/${batchResult.totalCount}`);
|
||||
|
||||
// 测试 5: 重复消息检测
|
||||
console.log('\n🔄 测试 5: 重复消息检测');
|
||||
const duplicateResult = await db.insertPushMessage(healthMessage);
|
||||
console.log('✅ 重复消息检测:', duplicateResult.isDuplicate ? '检测到重复' : '未检测到重复');
|
||||
|
||||
// 测试 6: 获取统计信息
|
||||
console.log('\n📊 测试 6: 统计信息获取');
|
||||
const stats = await db.getMessageStats(24);
|
||||
console.log('✅ 统计信息获取成功:', stats.length, '种消息类型');
|
||||
stats.forEach(stat => {
|
||||
console.log(` - ${stat.push_type}: ${stat.total_count} 条消息`);
|
||||
});
|
||||
|
||||
// 测试 7: 获取健康状态
|
||||
console.log('\n🏥 测试 7: 系统健康状态');
|
||||
const health = await db.getHealthStatus();
|
||||
console.log('✅ 系统健康状态:', health.database.connected ? '正常' : '异常');
|
||||
console.log(` - 总消息数: ${health.database.stats.total_messages}`);
|
||||
console.log(` - 最近1小时: ${health.database.stats.messages_last_hour}`);
|
||||
|
||||
// 测试 8: 获取消息列表
|
||||
console.log('\n📋 测试 8: 消息列表获取');
|
||||
const messages = await db.getPushMessages({ limit: 5 });
|
||||
console.log('✅ 消息列表获取成功:', messages.length, '条消息');
|
||||
|
||||
// 测试 9: HTTP API 测试
|
||||
console.log('\n🌐 测试 9: HTTP API 测试');
|
||||
await testHttpAPI();
|
||||
|
||||
console.log('\n🎉 所有测试完成!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 测试失败:', error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function testHttpAPI() {
|
||||
try {
|
||||
// 测试健康检查端点
|
||||
const healthResponse = await fetch('http://localhost:3001/api/health');
|
||||
if (healthResponse.ok) {
|
||||
console.log('✅ 健康检查 API 可用');
|
||||
} else {
|
||||
console.log('❌ 健康检查 API 不可用');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('ℹ️ HTTP API 测试跳过 (服务未运行)');
|
||||
}
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
if (require.main === module) {
|
||||
testService()
|
||||
.then(() => {
|
||||
console.log('\n✅ 测试完成,服务运行正常!');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('\n❌ 测试失败:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { testService };
|
||||
Reference in New Issue
Block a user