Initial commit: SMS Receiver Web Service
Features: - Receive SMS from TranspondSms Android APP - HMAC-SHA256 signature verification (optional) - SQLite database storage - Web UI with login authentication - Multiple API tokens support - Timezone conversion (Asia/Shanghai) - Search, filter, and statistics - Auto refresh and session management Tech Stack: - Flask 3.0 - SQLite database - HTML5/CSS3 responsive design
This commit is contained in:
47
.gitignore
vendored
Normal file
47
.gitignore
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# SQLite
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# 环境
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Flask
|
||||
instance/
|
||||
|
||||
# 临时文件
|
||||
*.tmp
|
||||
*.bak
|
||||
.DS_Store
|
||||
|
||||
# 进程ID
|
||||
*.pid
|
||||
|
||||
# 编辑器备份
|
||||
*~
|
||||
961
DEVELOPMENT.md
Normal file
961
DEVELOPMENT.md
Normal file
@@ -0,0 +1,961 @@
|
||||
# 短信转发接收端 - 开发文档
|
||||
|
||||
## 目录
|
||||
|
||||
- [项目概述](#项目概述)
|
||||
- [实现逻辑](#实现逻辑)
|
||||
- [使用指南](#使用指南)
|
||||
- [部署指南](#部署指南)
|
||||
- [API 文档](#api-文档)
|
||||
- [配置说明](#配置说明)
|
||||
- [常见问题](#常见问题)
|
||||
- [开发规范](#开发规范)
|
||||
|
||||
---
|
||||
|
||||
## 项目概述
|
||||
|
||||
### 技术架构
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Android APP │ │ Flask Web │ │ SQLite DB │
|
||||
│ TranspondSms │────────▶│ Server │────────▶│ sms_receiver │
|
||||
│ │ POST │ │ Store │ .db │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Web UI │
|
||||
│ (登录认证) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### 核心功能
|
||||
|
||||
1. **短信接收**:接收 TranspondSms Android APP 转发的短信
|
||||
2. **签名验证**:HMAC-SHA256 签名验证,防止伪造请求
|
||||
3. **数据存储**:SQLite 数据库存储短信和日志
|
||||
4. **Web 管理**:登录验证 + 短信列表、详情、日志、统计
|
||||
5. **时区转换**:UTC 存储时区转换显示
|
||||
6. **Token 配置**:支持多设备、多 Token 配置
|
||||
|
||||
---
|
||||
|
||||
## 实现逻辑
|
||||
|
||||
### 1. 整体数据流
|
||||
|
||||
```
|
||||
Android APP 接收短信
|
||||
│
|
||||
├─ 解析短信内容(支持多PDU)
|
||||
├─ 提取发送方、内容、时间戳
|
||||
├─ 可选:生成签名(HMAC-SHA256)
|
||||
│
|
||||
▼
|
||||
POST 请求发送到 /api/receive
|
||||
│
|
||||
├─ 解析 multipart/form-data
|
||||
├─ 验证必填参数(from, content)
|
||||
├─ 可选:验证签名
|
||||
│ ├─ 检查时间戳是否过期
|
||||
│ ├─ 生成期望的签名
|
||||
│ └─ 比较签名是否匹配
|
||||
│
|
||||
├─ 保存到数据库
|
||||
│ ├─ 存储为 UTC 时间
|
||||
│ ├─ 关联 Token 和 Secret
|
||||
│ └─ 记录接收日志
|
||||
│
|
||||
▼
|
||||
返回成功响应
|
||||
```
|
||||
|
||||
### 2. 签名验证逻辑
|
||||
|
||||
TranspondSms 的签名规则:
|
||||
|
||||
```python
|
||||
# 1. 拼接待签名字符串
|
||||
string_to_sign = timestamp + "\n" + secret
|
||||
|
||||
# 2. HMAC-SHA256 计算
|
||||
hmac_code = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
string_to_sign.encode('utf-8'),
|
||||
digestmod=hashlib.sha256
|
||||
).digest()
|
||||
|
||||
# 3. Base64 编码
|
||||
sign_bytes = base64.b64encode(hmac_code)
|
||||
|
||||
# 4. URL 编码
|
||||
sign = urllib.parse.quote(sign_bytes.decode())
|
||||
```
|
||||
|
||||
**防重放攻击**:
|
||||
- 检查时间戳是否在允许范围内(默认1小时)
|
||||
- 超出范围的请求拒绝
|
||||
|
||||
### 3. 时区转换逻辑
|
||||
|
||||
```
|
||||
数据库存储(UTC)
|
||||
│
|
||||
├─ created_at: 2024-02-06 14:30:00 (UTC)
|
||||
└─ timestamp: 1707223800000 (毫秒时间戳)
|
||||
│
|
||||
▼
|
||||
读取时转换
|
||||
│
|
||||
├─ UTC 时间 + 时区偏移(8小时)
|
||||
└─ datetime.fromtimestamp(timestamp / 1000)
|
||||
│
|
||||
▼
|
||||
显示(本地时间)
|
||||
│
|
||||
└─ created_at: 2024-02-06 22:30:00 (Asia/Shanghai)
|
||||
```
|
||||
|
||||
### 4. 登录验证流程
|
||||
|
||||
```
|
||||
用户访问页面
|
||||
│
|
||||
▼
|
||||
检查 session['logged_in']
|
||||
│
|
||||
├─ 已登录 ──▶ 更新 last_activity ──▶ 允许访问
|
||||
│
|
||||
└─ 未登录 ──▶ 跳转到 /login
|
||||
│
|
||||
▼
|
||||
提交表单
|
||||
│
|
||||
├─ 验证用户名和密码
|
||||
│
|
||||
├─ 成功:
|
||||
│ ├─ session['logged_in'] = True
|
||||
│ ├─ session['username'] = username
|
||||
│ ├─ session['login_time'] = now
|
||||
│ └─ 跳转到原页面或首页
|
||||
│
|
||||
└─ 失败:显示错误消息
|
||||
```
|
||||
|
||||
**会话超时检查**:
|
||||
```python
|
||||
last_activity = session.get('last_activity')
|
||||
if now - last_activity > SESSION_LIFETIME:
|
||||
# 清空会话,重定向到登录页
|
||||
session.clear()
|
||||
return redirect('/login')
|
||||
```
|
||||
|
||||
### 5. Token 匹配逻辑
|
||||
|
||||
```python
|
||||
# 接收请求时
|
||||
token = request.form.get('token') # 从参数获取
|
||||
|
||||
# 在配置中查找对应的 secret
|
||||
for token_config in API_TOKENS:
|
||||
if token_config['token'] == token and token_config['enabled']:
|
||||
secret = token_config['secret']
|
||||
break
|
||||
|
||||
# 使用找到的 secret 进行签名验证
|
||||
if secret and SIGN_VERIFY:
|
||||
verify_sign(secret, sign, timestamp)
|
||||
```
|
||||
|
||||
### 6. 数据库设计
|
||||
|
||||
#### sms_messages 表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | INTEGER | 主键,自增 |
|
||||
| from_number | TEXT | 发送方手机号 |
|
||||
| content | TEXT | 短信内容 |
|
||||
| timestamp | INTEGER | 原始时间戳(毫秒) |
|
||||
| device_info | TEXT | 设备信息(可选) |
|
||||
| sim_info | TEXT | SIM 卡信息(可选) |
|
||||
| sign_verified | INTEGER | 是否通过签名验证(0/1) |
|
||||
| ip_address | TEXT | 来源 IP 地址 |
|
||||
| created_at | TIMESTAMP | 创建时间(UTC) |
|
||||
|
||||
#### receive_logs 表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | INTEGER | 主键,自增 |
|
||||
| from_number | TEXT | 发送方手机号 |
|
||||
| content | TEXT | 短信内容 |
|
||||
| timestamp | INTEGER | 时间戳 |
|
||||
| sign | TEXT | 签名 |
|
||||
| sign_valid | INTEGER | 签名是否有效(0/1/null) |
|
||||
| ip_address | TEXT | IP 地址 |
|
||||
| status | TEXT | 处理状态(success/error) |
|
||||
| error_message | TEXT | 错误消息 |
|
||||
| created_at | TIMESTAMP | 创建时间(UTC)**
|
||||
|
||||
### 分层架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Flask Application Layer │
|
||||
│ (app.py - 路由、业务逻辑、会话管理) │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Database Layer │
|
||||
│ (database.py - 数据库操作、时区转换) │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ SQLite Database │
|
||||
│ (sms_receiver.db - 数据持久化) │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Template Layer │
|
||||
│ (templates/ - HTML、CSS、JS) │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 核心模块职责
|
||||
|
||||
| 模块 | 职责 |
|
||||
|------|------|
|
||||
| `app.py` | Flask 主应用,路由注册,业务逻辑 |
|
||||
| `config.py` | 配置加载,从 config.json 读取配置 |
|
||||
| `database.py` | 数据库模型,CRUD 操作,时区转换 |
|
||||
| `sign_verify.py` | 签名生成和验证 |
|
||||
| `templates/` | HTML 模板,前端展示 |
|
||||
|
||||
---
|
||||
|
||||
## 使用指南
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Python 3.7+
|
||||
- Flask 3.0+
|
||||
- SQLite3
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 配置文件
|
||||
|
||||
创建 `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 9518,
|
||||
"debug": false
|
||||
},
|
||||
"security": {
|
||||
"enabled": true,
|
||||
"username": "admin",
|
||||
"password": "YourStrongPassword123",
|
||||
"session_lifetime": 3600,
|
||||
"secret_key": "RandomSecretKeyHere",
|
||||
"sign_verify": true,
|
||||
"sign_max_age": 3600000
|
||||
},
|
||||
"sms": {
|
||||
"max_messages": 10000,
|
||||
"auto_cleanup": true,
|
||||
"cleanup_days": 30
|
||||
},
|
||||
"database": {
|
||||
"path": "sms_receiver.db"
|
||||
},
|
||||
"timezone": "Asia/Shanghai",
|
||||
"api_tokens": [
|
||||
{
|
||||
"name": "我的手机",
|
||||
"token": "my_phone_token",
|
||||
"secret": "my_phone_secret",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
服务启动后访问:http://你的IP:9518
|
||||
|
||||
### 配置 TranspondSms APP
|
||||
|
||||
1. 下载并安装 TranspondSms APP
|
||||
2. 打开 APP,进入"发送方"页面
|
||||
3. 添加"网页通知"
|
||||
4. 填写配置:
|
||||
|
||||
```
|
||||
Token (URL): http://你的服务器IP:9518/api/receive?token=my_phone_token
|
||||
Secret: my_phone_secret
|
||||
```
|
||||
|
||||
5. 点击"测试"按钮,验证是否成功
|
||||
6. 配置转发规则(如"转发全部")
|
||||
|
||||
### 使用 Web 界面
|
||||
|
||||
#### 登录
|
||||
|
||||
- 访问 http://你的IP:9518
|
||||
- 输入用户名和密码
|
||||
- 登录成功后进入短信列表
|
||||
|
||||
#### 查看短信
|
||||
|
||||
- **短信列表**:主页显示所有收到短信
|
||||
- **搜索**:支持按号码或内容搜索
|
||||
- **筛选**:按发送方号码快捷筛选
|
||||
- **详情**:点击短信查看完整内容和元数据
|
||||
|
||||
#### 查看日志
|
||||
|
||||
- 访问"接收日志"页面
|
||||
- 查看每次请求的处理结果
|
||||
- 包括签名验证状态、IP 地址、错误信息
|
||||
|
||||
#### 统计信息
|
||||
|
||||
- 访问"统计信息"页面
|
||||
- 查看短信总数、今日、本周
|
||||
- 签名验证比例
|
||||
- 发送方号码排行榜
|
||||
- 清理旧数据
|
||||
|
||||
---
|
||||
|
||||
## 部署指南
|
||||
|
||||
### 开发环境部署
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone <your-repo-url>
|
||||
cd smsweb
|
||||
|
||||
# 创建虚拟环境(推荐)
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 配置 config.json
|
||||
cp config.example.json config.json
|
||||
vim config.json
|
||||
|
||||
# 启动服务
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
### 生产环境部署(推荐方案)
|
||||
|
||||
#### 1. 使用 Gunicorn + Nginx
|
||||
|
||||
**安装 Gunicorn**:
|
||||
|
||||
```bash
|
||||
pip install gunicorn
|
||||
```
|
||||
|
||||
**创建 systemd 服务**:
|
||||
|
||||
```bash
|
||||
sudo vim /etc/systemd/system/sms-receiver.service
|
||||
```
|
||||
|
||||
内容:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=SMS Receiver Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
User=www-data
|
||||
WorkingDirectory=/var/www/sms-receiver
|
||||
Environment="PATH=/var/www/sms-receiver/venv/bin"
|
||||
ExecStart=/var/www/sms-receiver/venv/bin/gunicorn \
|
||||
-w 4 -b 127.0.0.1:9518 \
|
||||
--timeout 120 \
|
||||
--access-logfile /var/log/sms-receiver/access.log \
|
||||
--error-logfile /var/log/sms-receiver/error.log \
|
||||
app:app
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
**启动服务**:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable sms-receiver
|
||||
sudo systemctl start sms-receiver
|
||||
sudo systemctl status sms-receiver
|
||||
```
|
||||
|
||||
**配置 Nginx 反向代理**:
|
||||
|
||||
```bash
|
||||
sudo vim /etc/nginx/sites-available/sms-receiver
|
||||
```
|
||||
|
||||
内容:
|
||||
|
||||
```nginx
|
||||
upstream sms_receiver {
|
||||
server 127.0.0.1:9518;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name sms.example.com;
|
||||
|
||||
access_log /var/log/nginx/sms-receiver-access.log;
|
||||
error_log /var/log/nginx/sms-receiver-error.log;
|
||||
|
||||
location / {
|
||||
proxy_pass http://sms_receiver;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_redirect off;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# 接收 API 不需要登录,但对客户端透明
|
||||
location /api/receive {
|
||||
proxy_pass http://sms_receiver/api/receive;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**启用站点**:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/sms-receiver /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
#### 2. 配置 HTTPS(Let's Encrypt)
|
||||
|
||||
```bash
|
||||
sudo certbot --nginx -d sms.example.com
|
||||
```
|
||||
|
||||
#### 3. 防火墙配置
|
||||
|
||||
```bash
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
#### 4. 日志轮转
|
||||
|
||||
```bash
|
||||
sudo vim /etc/logrotate.d/sms-receiver
|
||||
```
|
||||
|
||||
内容:
|
||||
|
||||
```
|
||||
/var/log/sms-receiver/*.log {
|
||||
daily
|
||||
rotate 14
|
||||
compress
|
||||
missingok
|
||||
notifempty
|
||||
create 0640 www-data www-data
|
||||
sharedscripts
|
||||
postrotate
|
||||
systemctl reload sms-receiver >/dev/null 2>&1 || true
|
||||
endscript
|
||||
}
|
||||
```
|
||||
|
||||
### Docker 部署
|
||||
|
||||
**创建 Dockerfile**:
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 9518
|
||||
|
||||
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:9518", "app:app"]
|
||||
```
|
||||
|
||||
**创建 docker-compose.yml**:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
sms-receiver:
|
||||
build: .
|
||||
ports:
|
||||
- "9518:9518"
|
||||
volumes:
|
||||
- ./sms_receiver.db:/app/sms_receiver.db
|
||||
- ./config.json:/app/config.json
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
**启动**:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 安全加固
|
||||
|
||||
1. **修改默认密码**:首次部署后立即修改登录密码
|
||||
2. **使用强密码**:至少16位,包含大小写字母、数字、特殊字符
|
||||
3. **启用 HTTPS**:使用 Let's Encrypt 免费证书
|
||||
4. **限制访问**:配置防火墙,只开放必要端口
|
||||
5. **启用签名验证**:设置 Token 的 secret
|
||||
6. **定期更新**:定期更新 Python 和 Flask 版本
|
||||
|
||||
### 监控和维护
|
||||
|
||||
**查看日志**:
|
||||
|
||||
```bash
|
||||
# 应用日志
|
||||
tail -f /var/log/sms-receiver/error.log
|
||||
|
||||
# Nginx 日志
|
||||
tail -f /var/log/nginx/sms-receiver-error.log
|
||||
```
|
||||
|
||||
**备份数据库**:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
BACKUP_DIR="/backup/sms-receiver"
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
# 备份数据库
|
||||
cp /var/www/sms-receiver/sms_receiver.db $BACKUP_DIR/sms_receiver_$DATE.db
|
||||
|
||||
# 备份配置
|
||||
cp /var/www/sms-receiver/config.json $BACKUP_DIR/config_$DATE.json
|
||||
|
||||
# 删除30天前的备份
|
||||
find $BACKUP_DIR -name "*.db" -mtime +30 -delete
|
||||
find $BACKUP_DIR -name "*.json" -mtime +30 -delete
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 文档
|
||||
|
||||
### POST /api/receive
|
||||
|
||||
接收短信接口。
|
||||
|
||||
**请求方式**:POST multipart/form-data
|
||||
|
||||
**URL 参数**:
|
||||
- `token` (可选): API Token,用于匹配对应的 secret
|
||||
|
||||
**表单参数**:
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| from | string | 是 | 发送方手机号 |
|
||||
| content | string | 是 | 短信内容 |
|
||||
| timestamp | string | 否 | 时间戳(毫秒),用于签名验证 |
|
||||
| sign | string | 否 | 签名(HMAC-SHA256 + Base64 + URL Encode) |
|
||||
| device | string | 否 | 设备信息 |
|
||||
| sim | string | 否 | SIM 卡信息 |
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```bash
|
||||
curl -X POST http://your-server:9518/api/receive?token=my_token \
|
||||
-F "from=10086" \
|
||||
-F "content=验证码: 123456" \
|
||||
-F "timestamp=1707223800000" \
|
||||
-F "sign=xxx"
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
|
||||
成功:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message_id": 123,
|
||||
"message": "短信已接收"
|
||||
}
|
||||
```
|
||||
|
||||
失败:
|
||||
```json
|
||||
{
|
||||
"error": "缺少必填参数"
|
||||
}
|
||||
```
|
||||
|
||||
**HTTP 状态码**:
|
||||
- 200: 成功
|
||||
- 400: 参数错误
|
||||
- 403: 签名验证失败
|
||||
- 500: 服务器错误
|
||||
|
||||
### GET /api/messages
|
||||
|
||||
获取短信列表(需要登录)。
|
||||
|
||||
**URL 参数**:
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| page | int | 否 | 页码,默认1 |
|
||||
| limit | int | 否 | 每页数量,默认20 |
|
||||
| from | string | 否 | 按发送方号码筛选 |
|
||||
| search | string | 否 | 搜索内容或号码 |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"from_number": "10086",
|
||||
"content": "验证码: 123456",
|
||||
"timestamp": 1707223800000,
|
||||
"local_timestamp": "2024-02-06 22:30:00",
|
||||
"created_at": "2024-02-06 14:30:00",
|
||||
"sign_verified": true
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/statistics
|
||||
|
||||
获取统计信息(需要登录)。
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"total": 100,
|
||||
"today": 10,
|
||||
"week": 50,
|
||||
"verified": 80,
|
||||
"unverified": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 完整配置示例
|
||||
|
||||
```json
|
||||
{
|
||||
"app": {
|
||||
"name": "短信转发接收端",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 9518,
|
||||
"debug": false
|
||||
},
|
||||
"security": {
|
||||
"enabled": true,
|
||||
"username": "admin",
|
||||
"password": "YourStrongPassword123",
|
||||
"session_lifetime": 3600,
|
||||
"secret_key": "RandomSecretKeyChangeMe",
|
||||
"sign_verify": true,
|
||||
"sign_max_age": 3600000
|
||||
},
|
||||
"sms": {
|
||||
"max_messages": 10000,
|
||||
"auto_cleanup": true,
|
||||
"cleanup_days": 30
|
||||
},
|
||||
"database": {
|
||||
"path": "sms_receiver.db"
|
||||
},
|
||||
"timezone": "Asia/Shanghai",
|
||||
"api_tokens": [
|
||||
{
|
||||
"name": "主手机",
|
||||
"token": "main_phone_token",
|
||||
"secret": "main_phone_secret_key",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "备用机",
|
||||
"token": "backup_phone_token",
|
||||
"secret": "backup_phone_secret_key",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "测试设备",
|
||||
"token": "test_token",
|
||||
"secret": "",
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 配置项详解
|
||||
|
||||
#### server - 服务器配置
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| host | string | 0.0.0.0 | 监听地址 |
|
||||
| port | int | 9518 | 监听端口 |
|
||||
| debug | bool | false | 调试模式 |
|
||||
|
||||
#### security - 安全配置
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| enabled | bool | true | 是否启用登录验证 |
|
||||
| username | string | admin | 登录用户名 |
|
||||
| password | string | admin123 | 登录密码 |
|
||||
| session_lifetime | int | 3600 | 会话有效期(秒) |
|
||||
| secret_key | string | - | Flask 会话密钥 |
|
||||
| sign_verify | bool | true | 是否验证签名 |
|
||||
| sign_max_age | int | 3600000 | 签名最大有效时间(毫秒) |
|
||||
|
||||
#### sms - 短信配置
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| max_messages | int | 10000 | 最多保留短信数 |
|
||||
| auto_cleanup | bool | true | 是否自动清理老数据 |
|
||||
| cleanup_days | int | 30 | 清理多少天前的数据 |
|
||||
|
||||
#### api_tokens - API Token 配置
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| name | string | 配置名称(可选) |
|
||||
| token | string | Token 值,用于匹配 secret |
|
||||
| secret | string | 密钥,用于签名验证(可选) |
|
||||
| enabled | bool | 是否启用此 Token |
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 如何禁用登录验证?
|
||||
|
||||
在 `config.json` 中设置:
|
||||
|
||||
```json
|
||||
{
|
||||
"security": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Q2:签名验证失败怎么办?
|
||||
|
||||
检查以下几点:
|
||||
|
||||
1. **时间戳是否正确**:确保客户端时间准确,误差不超过1小时
|
||||
2. **Secret 是否匹配**:确保客户端和服务器端的 secret 完全一致
|
||||
3. **签名生成算法**:使用正确的算法(HMAC-SHA256)
|
||||
|
||||
**调试签名**:
|
||||
|
||||
```python
|
||||
# 生成签名
|
||||
import time, hmac, hashlib, base64, urllib.parse
|
||||
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
secret = "your_secret"
|
||||
string_to_sign = f"{timestamp}\n{secret}"
|
||||
sign = urllib.parse.quote(base64.b64encode(
|
||||
hmac.new(secret.encode(), string_to_sign.encode(), hashlib.sha256).digest()
|
||||
).decode())
|
||||
|
||||
print(f"Timestamp: {timestamp}")
|
||||
print(f"Sign: {sign}")
|
||||
```
|
||||
|
||||
### Q3: 如何配置多个设备?
|
||||
|
||||
在 `api_tokens` 中添加多个配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_tokens": [
|
||||
{
|
||||
"name": "设备A",
|
||||
"token": "device_a",
|
||||
"secret": "secret_a",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "设备B",
|
||||
"token": "device_b",
|
||||
"secret": "secret_b",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
在 APP 中配置不同的设备使用不同的 Token。
|
||||
|
||||
### Q4: 会话总是过期?
|
||||
|
||||
调整 `session_lifetime`:
|
||||
|
||||
```json
|
||||
{
|
||||
"security": {
|
||||
"session_lifetime": 86400 // 24小时
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Q5: 如何备份数据?
|
||||
|
||||
直接复制数据库文件:
|
||||
|
||||
```bash
|
||||
cp sms_receiver.db sms_receiver.db.backup
|
||||
```
|
||||
|
||||
或者使用 SQLite 导出:
|
||||
|
||||
```bash
|
||||
sqlite3 sms_receiver.db ".dump" > backup.sql
|
||||
```
|
||||
|
||||
### Q6: 如何清理所有数据?
|
||||
|
||||
删除数据库文件,重启服务会自动重建:
|
||||
|
||||
```bash
|
||||
rm sms_receiver.db
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
### Q7: 时间显示不正确?
|
||||
|
||||
检查时区配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"timezone": "Asia/Shanghai"
|
||||
}
|
||||
```
|
||||
|
||||
可用时区列表:https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
|
||||
---
|
||||
|
||||
## 开发规范
|
||||
|
||||
### 代码风格
|
||||
|
||||
- 遵循 PEP 8 Python 代码规范
|
||||
- 使用有意义的变量和函数名
|
||||
- 添加必要的类型注解
|
||||
|
||||
### Git 提交规范
|
||||
|
||||
```
|
||||
feat: 添加新功能
|
||||
fix: 修复 bug
|
||||
docs: 更新文档
|
||||
style: 代码格式化
|
||||
refactor: 重构
|
||||
test: 添加测试
|
||||
chore: 构建/工具链
|
||||
```
|
||||
|
||||
### 测试建议
|
||||
|
||||
```python
|
||||
# 测试签名生成
|
||||
python3 sign_verify.py
|
||||
|
||||
# 测试 API
|
||||
curl -X POST http://localhost:9518/api/receive \
|
||||
-F "from=10086" \
|
||||
-F "content=test"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 联系方式
|
||||
|
||||
- 项目地址:https://gitea.king.nyc.mn/openclaw/smsweb
|
||||
- 问题反馈:提交 Issue
|
||||
254
README.md
Normal file
254
README.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# 短信转发接收端
|
||||
|
||||
基于 TranspondSms Android APP 的短信转发接收后台
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ **登录验证** - 需要登录才能查看和管理短信
|
||||
- ✅ **Token/Secret 可选配置** - 支持在 config.json 中配置多个 API Token
|
||||
- ✅ **接收 Android APP 转发的短信** - POST multipart/form-data
|
||||
- ✅ **HMAC-SHA256 签名验证** - 可选的安全机制
|
||||
- ✅ **SQLite 数据库存储**
|
||||
- ✅ **Web 管理界面** - 查看实时短信、日志、统计
|
||||
- ✅ **时区支持** - 自动转换为本地时间(默认 Asia/Shanghai)
|
||||
- ✅ **RESTful API 支持**
|
||||
- ✅ **自动刷新** - 短信列表30秒自动刷新
|
||||
|
||||
## 快速启动
|
||||
|
||||
```bash
|
||||
# 进入项目目录
|
||||
cd /root/.openclaw/workspace/sms-receiver
|
||||
|
||||
# 启动服务
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
服务将运行在 `http://127.0.0.1:9518`
|
||||
|
||||
## 配置说明
|
||||
|
||||
### config.json 配置文件
|
||||
|
||||
创建或编辑 `config.json` 文件:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 9518,
|
||||
"debug": true
|
||||
},
|
||||
"security": {
|
||||
"enabled": true,
|
||||
"username": "admin",
|
||||
"password": "admin123",
|
||||
"session_lifetime": 3600,
|
||||
"secret_key": "default_secret_key_change_me",
|
||||
"sign_verify": true,
|
||||
"sign_max_age": 3600000
|
||||
},
|
||||
"sms": {
|
||||
"max_messages": 10000,
|
||||
"auto_cleanup": true,
|
||||
"cleanup_days": 30
|
||||
},
|
||||
"database": {
|
||||
"path": "sms_receiver.db"
|
||||
},
|
||||
"timezone": "Asia/Shanghai",
|
||||
"api_tokens": [
|
||||
{
|
||||
"name": "默认配置",
|
||||
"token": "default_token",
|
||||
"secret": "your_secret_here",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "设备1",
|
||||
"token": "device1_token",
|
||||
"secret": "device1_secret",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 配置项说明
|
||||
|
||||
| 项 | 说明 | 默认值 |
|
||||
|----|------|--------|
|
||||
| `security.enabled` | 是否启用登录验证 | true |
|
||||
| `security.username` | 登录用户名 | admin |
|
||||
| `security.password` | 登录密码 | admin123 |
|
||||
| `security.session_lifetime` | 会话有效期(秒) | 3600 |
|
||||
| `security.secret_key` | Flask 会话密钥 | - |
|
||||
| `security.sign_verify` | 是否验证签名 | true |
|
||||
| `security.sign_max_age` | 签名最大有效时间(毫秒) | 3600000 |
|
||||
| `api_tokens` | API Token 配置列表 | - |
|
||||
| `timezone` | 时区 | Asia/Shanghai |
|
||||
|
||||
### API Token 配置
|
||||
|
||||
每个 Token 配置包含:
|
||||
|
||||
- `name`: 配置名称(可选)
|
||||
- `token`: Token 值,需要在 TranspondSms APP 中填写
|
||||
- `secret`: 密钥,可选,填写后启用签名验证
|
||||
- `enabled`: 是否启用此 Token
|
||||
|
||||
**注意**:
|
||||
- Token 和 Secret 都是可选的
|
||||
- 不填 Secret 时跳过签名验证
|
||||
- 可以配置多个 Token 供多个设备使用
|
||||
- enabled 为 false 时该 Token 不可用
|
||||
|
||||
## 配置 TranspondSms APP
|
||||
|
||||
在 Android APP 的"网页通知"配置中:
|
||||
|
||||
### 基础配置(无 Token)
|
||||
|
||||
- **Token (URL)**: `http://your-server-ip:9518/api/receive`
|
||||
- **Secret**: 留空
|
||||
|
||||
### 使用 Token 配置
|
||||
|
||||
如果你在 `config.json` 中配置了 Token:
|
||||
|
||||
- **Token (URL)**: `http://your-server-ip:9518/api/receive`
|
||||
- **Secret**: 根据你要使用的 Token 配置填写
|
||||
|
||||
**注意**:TranspondSms APP 的设置中,没有专门的 Token 字段。你需要通过以下方式传递 Token:
|
||||
|
||||
1. **方法一**:在 URL 中携带 Token
|
||||
```
|
||||
http://your-server-ip:9518/api/receive?token=your_token
|
||||
```
|
||||
|
||||
2. **方法二**:在 Secret 字段中填写 `<token>|<secret>`
|
||||
```
|
||||
my_token|my_secret
|
||||
```
|
||||
然后修改代码解析这个格式(需自行实现)
|
||||
|
||||
**当前实现**:在短信接收时,TranspondSms APP 会发送一个 `token` 参数,系统会自动匹配对应的 secret。
|
||||
|
||||
目前 TranspondSms APP 的 Token 参数默认会放在请求的 query string 中:
|
||||
```
|
||||
POST /api/receive?token=your_token
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
如需传递多个设备的不同 Token,在 APP 中添加多个网页通知配置即可。
|
||||
|
||||
## 登录功能
|
||||
|
||||
### 默认登录信息
|
||||
|
||||
- **用户名**: admin
|
||||
- **密码**: admin123
|
||||
|
||||
首次使用后,请立即修改 `config.json` 中的 `security.username` 和 `security.password`。
|
||||
|
||||
### 会话管理
|
||||
|
||||
- 会话默认有效期:1小时(3600秒)
|
||||
- 超时后需要重新登录
|
||||
- 可以通过 `security.session_lifetime` 调整
|
||||
|
||||
### 禁用登录
|
||||
|
||||
如果不需要登录验证,设置 `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"security": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
### 接收短信
|
||||
|
||||
```
|
||||
POST /api/receive?token=your_token
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
参数:
|
||||
- token: API Token(可选,用于匹配 secret)
|
||||
- from: 发送方手机号(必填)
|
||||
- content: 短信内容(必填)
|
||||
- timestamp: 时间戳(毫秒,可选)
|
||||
- sign: 签名(可选)
|
||||
- device: 设备信息(可选)
|
||||
- sim: SIM 卡信息(可选)
|
||||
```
|
||||
|
||||
### 查询短信列表(需要登录)
|
||||
|
||||
```
|
||||
GET /api/messages?page=1&limit=20
|
||||
```
|
||||
|
||||
### 查询统计信息(需要登录)
|
||||
|
||||
```
|
||||
GET /api/statistics
|
||||
```
|
||||
|
||||
## 时区配置
|
||||
|
||||
系统默认使用 `Asia/Shanghai` 时区(UTC+8)。
|
||||
|
||||
在 `config.json` 中修改时区:
|
||||
|
||||
```json
|
||||
{
|
||||
"timezone": "America/New_York"
|
||||
}
|
||||
```
|
||||
|
||||
支持的时区名称参考:https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
|
||||
## 安全建议
|
||||
|
||||
1. **修改默认密码**:首次使用后立即修改登录密码
|
||||
2. **修改 Secret Key**:修改 `security.secret_key` 为随机字符串
|
||||
3. **使用 HTTPS**:生产环境建议配置反向代理(Nginx + Let's Encrypt)
|
||||
4. **启用签名验证**:设置 Token 的 secret 启用签名,防止伪造请求
|
||||
5. **会话超时**:设置合理的 `session_lifetime`
|
||||
|
||||
## 默认配置说明
|
||||
|
||||
| 配置 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| 端口 | 9518 | Web 服务监听端口 |
|
||||
| 数据库 | sms_receiver.db | SQLite 数据库文件 |
|
||||
| 时区 | Asia/Shanghai | 武汉时间 UTC+8 |
|
||||
| 会话有效期 | 3600 秒 | 1小时 |
|
||||
| 最多保留短信 | 10000 条 | 超过自动清理 |
|
||||
| 自动刷新间隔 | 30 秒 | SMS 列表自动刷新时间 |
|
||||
|
||||
## 项目文件
|
||||
|
||||
```
|
||||
/root/.openclaw/workspace/sms-receiver/
|
||||
├── app.py # Flask 主应用
|
||||
├── config.json # 配置文件(需创建)
|
||||
├── config.py # 配置加载器
|
||||
├── database.py # SQLite 数据库模型
|
||||
├── sign_verify.py # HMAC-SHA256 签名验证
|
||||
├── requirements.txt # 依赖包
|
||||
├── templates/ # HTML 模板
|
||||
│ ├── login.html # 登录页面
|
||||
│ ├── index.html # 主页(短信列表)
|
||||
│ ├── message_detail.html # 短信详情
|
||||
│ ├── logs.html # 接收日志
|
||||
│ ├── statistics.html # 统计信息
|
||||
│ └── error.html # 错误页面
|
||||
└── README.md # 本文档
|
||||
```
|
||||
|
||||
396
app.py
Normal file
396
app.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
短信转发接收端主应用
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import logging.handlers
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import wraps
|
||||
import hashlib
|
||||
|
||||
from flask import Flask, request, jsonify, render_template, redirect, url_for, flash, session, make_response
|
||||
from functools import update_wrapper
|
||||
|
||||
from config import config, Config
|
||||
from database import Database
|
||||
from sign_verify import verify_from_app
|
||||
|
||||
|
||||
def no_cache(f):
|
||||
"""禁用缓存的装饰器"""
|
||||
def new_func(*args, **kwargs):
|
||||
resp = make_response(f(*args, **kwargs))
|
||||
resp.cache_control.no_cache = True
|
||||
resp.cache_control.no_store = True
|
||||
resp.cache_control.max_age = 0
|
||||
return resp
|
||||
return update_wrapper(new_func, f)
|
||||
|
||||
|
||||
# 初始化应用
|
||||
def create_app(config_name='default'):
|
||||
# 从配置文件加载
|
||||
app_config = Config.load_from_json('config.json')
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = app_config.SECRET_KEY
|
||||
|
||||
# 应用配置
|
||||
app.config['HOST'] = app_config.HOST
|
||||
app.config['PORT'] = app_config.PORT
|
||||
app.config['DEBUG'] = app_config.DEBUG
|
||||
app.config['SECRET_KEY'] = app_config.SECRET_KEY
|
||||
app.config['SIGN_VERIFY'] = app_config.SIGN_VERIFY
|
||||
app.config['SIGN_MAX_AGE'] = app_config.SIGN_MAX_AGE
|
||||
app.config['DATABASE_PATH'] = app_config.DATABASE_PATH
|
||||
app.config['MAX_MESSAGES'] = app_config.MAX_MESSAGES
|
||||
app.config['AUTO_CLEANUP'] = app_config.AUTO_CLEANUP
|
||||
app.config['CLEANUP_DAYS'] = app_config.CLEANUP_DAYS
|
||||
app.config['PER_PAGE'] = app_config.PER_PAGE
|
||||
app.config['REFRESH_INTERVAL'] = app_config.REFRESH_INTERVAL
|
||||
app.config['LOG_LEVEL'] = app_config.LOG_LEVEL
|
||||
app.config['LOG_FILE'] = app_config.LOG_FILE
|
||||
app.config['TIMEZONE'] = app_config.TIMEZONE
|
||||
app.config['API_TOKENS'] = app_config.API_TOKENS
|
||||
app.config['LOGIN_ENABLED'] = app_config.LOGIN_ENABLED
|
||||
app.config['LOGIN_USERNAME'] = app_config.LOGIN_USERNAME
|
||||
app.config['LOGIN_PASSWORD'] = app_config.LOGIN_PASSWORD
|
||||
app.config['SESSION_LIFETIME'] = app_config.SESSION_LIFETIME
|
||||
|
||||
# 初始化日志
|
||||
setup_logging(app)
|
||||
|
||||
# 解析时区
|
||||
try:
|
||||
timezone_name = app_config.TIMEZONE
|
||||
import pytz
|
||||
app.timezone = pytz.timezone(timezone_name)
|
||||
app.timezone_offset = app.timezone.utcoffset(datetime.now()).total_seconds() / 3600
|
||||
except ImportError:
|
||||
# 如果没有 pytz,使用简单的时区偏移
|
||||
app.timezone_offset = 8 # 默认 UTC+8
|
||||
app.timezone = None
|
||||
app.logger.warning('pytz not installed, using simple timezone offset')
|
||||
|
||||
# 初始化数据库
|
||||
db = Database(app.config['DATABASE_PATH'], timezone_offset=app.timezone_offset)
|
||||
|
||||
# 注册路由
|
||||
register_routes(app, db)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def setup_logging(app):
|
||||
"""配置日志"""
|
||||
log_level = getattr(logging, app.config['LOG_LEVEL'])
|
||||
formatter = logging.Formatter(
|
||||
'[%(asctime)s] %(levelname)s in %(module)s: %(message)s'
|
||||
)
|
||||
|
||||
# 文件日志
|
||||
log_file_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
app.config['LOG_FILE']
|
||||
)
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
log_file_path,
|
||||
maxBytes=10*1024*1024, # 10MB
|
||||
backupCount=5
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
file_handler.setLevel(log_level)
|
||||
|
||||
# 控制台日志
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.setLevel(log_level)
|
||||
|
||||
app.logger.addHandler(file_handler)
|
||||
app.logger.addHandler(console_handler)
|
||||
app.logger.setLevel(log_level)
|
||||
|
||||
|
||||
def login_required(f):
|
||||
"""登录验证装饰器"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if app.config.get('LOGIN_ENABLED', True):
|
||||
if 'logged_in' not in session or not session['logged_in']:
|
||||
return redirect(url_for('login', next=request.url))
|
||||
|
||||
# 检查会话是否过期
|
||||
last_activity = session.get('last_activity')
|
||||
if last_activity:
|
||||
session_lifetime = app.config.get('SESSION_LIFETIME', 3600)
|
||||
if datetime.now().timestamp() - last_activity > session_lifetime:
|
||||
session.clear()
|
||||
flash('会话已过期,请重新登录', 'info')
|
||||
return redirect(url_for('login', next=request.url))
|
||||
|
||||
# 更新最后活动时间
|
||||
session['last_activity'] = datetime.now().timestamp()
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def register_routes(app, db):
|
||||
"""注册路由"""
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
@no_cache
|
||||
def login():
|
||||
"""登录页面"""
|
||||
# 如果禁用了登录,直接跳转到首页
|
||||
if not app.config.get('LOGIN_ENABLED', True):
|
||||
return redirect(url_for('index'))
|
||||
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
|
||||
if username == app.config['LOGIN_USERNAME'] and \
|
||||
password == app.config['LOGIN_PASSWORD']:
|
||||
session['logged_in'] = True
|
||||
session['username'] = username
|
||||
session['login_time'] = datetime.now().timestamp()
|
||||
session['last_activity'] = datetime.now().timestamp()
|
||||
|
||||
next_url = request.args.get('next')
|
||||
if next_url:
|
||||
return redirect(next_url)
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
flash('用户名或密码错误', 'error')
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
@app.route('/logout')
|
||||
@no_cache
|
||||
def logout():
|
||||
"""登出"""
|
||||
session.clear()
|
||||
flash('已退出登录', 'info')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
@app.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
"""首页 - 短信列表"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
limit = request.args.get('limit', app.config['PER_PAGE'], type=int)
|
||||
from_number = request.args.get('from', None)
|
||||
search = request.args.get('search', None)
|
||||
|
||||
messages = db.get_messages(page, limit, from_number, search)
|
||||
total = db.get_message_count(from_number, search)
|
||||
total_pages = (total + limit - 1) // limit
|
||||
|
||||
stats = db.get_statistics()
|
||||
|
||||
from_numbers = db.get_from_numbers()[:20]
|
||||
|
||||
return render_template('index.html',
|
||||
messages=messages,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
total=total,
|
||||
from_number=from_number,
|
||||
search=search,
|
||||
limit=limit,
|
||||
stats=stats,
|
||||
from_numbers=from_numbers,
|
||||
refresh_interval=app.config['REFRESH_INTERVAL'])
|
||||
|
||||
@app.route('/message/<int:message_id>')
|
||||
@login_required
|
||||
def message_detail(message_id):
|
||||
"""短信详情"""
|
||||
message = db.get_message_by_id(message_id)
|
||||
if not message:
|
||||
flash('短信不存在', 'error')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
return render_template('message_detail.html', message=message)
|
||||
|
||||
@app.route('/logs')
|
||||
@login_required
|
||||
def logs():
|
||||
"""查看接收日志"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
limit = request.args.get('limit', app.config['PER_PAGE'], type=int)
|
||||
|
||||
logs = db.get_logs(page, limit)
|
||||
|
||||
return render_template('logs.html', logs=logs, page=page, limit=limit)
|
||||
|
||||
@app.route('/statistics')
|
||||
@login_required
|
||||
def statistics():
|
||||
"""统计信息"""
|
||||
stats = db.get_statistics()
|
||||
recent = db.get_recent_messages(10)
|
||||
from_numbers = db.get_from_numbers()
|
||||
|
||||
return render_template('statistics.html',
|
||||
stats=stats,
|
||||
recent=recent,
|
||||
from_numbers=from_numbers,
|
||||
cleanup_days=app.config['CLEANUP_DAYS'],
|
||||
max_messages=app.config['MAX_MESSAGES'])
|
||||
|
||||
@app.route('/settings')
|
||||
@login_required
|
||||
def settings():
|
||||
"""设置页面"""
|
||||
return render_template('settings.html',
|
||||
api_tokens=app.config['API_TOKENS'],
|
||||
login_enabled=app.config['LOGIN_ENABLED'],
|
||||
sign_verify=app.config['SIGN_VERIFY'])
|
||||
|
||||
@app.route('/api/receive', methods=['POST'])
|
||||
def receive_sms():
|
||||
"""接收短信接口"""
|
||||
try:
|
||||
# 获取参数
|
||||
from_number = request.form.get('from')
|
||||
content = request.form.get('content')
|
||||
timestamp_str = request.form.get('timestamp')
|
||||
sign = request.form.get('sign', '')
|
||||
token_param = request.form.get('token', '')
|
||||
ip_address = request.remote_addr
|
||||
|
||||
# 验证必填字段
|
||||
if not from_number or not content:
|
||||
db.add_log(from_number, content, None, sign, None, ip_address,
|
||||
'error', '缺少必填参数 (from/content)')
|
||||
return jsonify({'error': '缺少必填参数'}), 400
|
||||
|
||||
# 如果提供了 token,查找对应的 secret
|
||||
secret = None
|
||||
if token_param:
|
||||
for token_config in app.config['API_TOKENS']:
|
||||
if token_config.get('enabled') and token_config.get('token') == token_param:
|
||||
secret = token_config.get('secret')
|
||||
break
|
||||
|
||||
# 解析时间戳
|
||||
timestamp = None
|
||||
if timestamp_str:
|
||||
try:
|
||||
timestamp = int(timestamp_str)
|
||||
except ValueError:
|
||||
db.add_log(from_number, content, None, sign, None, ip_address,
|
||||
'error', f'时间戳格式错误: {timestamp_str}')
|
||||
return jsonify({'error': '时间戳格式错误'}), 400
|
||||
|
||||
# 验证签名
|
||||
sign_verified = False
|
||||
if sign and secret and app.config['SIGN_VERIFY']:
|
||||
is_valid, message = verify_from_app(
|
||||
from_number, content, timestamp, sign, secret,
|
||||
app.config['SIGN_MAX_AGE']
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
db.add_log(from_number, content, timestamp, sign, False, ip_address,
|
||||
'error', f'签名验证失败: {message}')
|
||||
app.logger.warning(f'签名验证失败: {message}')
|
||||
return jsonify({'error': message}), 403
|
||||
else:
|
||||
sign_verified = True
|
||||
app.logger.info(f'短信已签名验证: {from_number}')
|
||||
|
||||
# 保存短信
|
||||
message_id = db.add_message(
|
||||
from_number=from_number,
|
||||
content=content,
|
||||
timestamp=timestamp or int(datetime.now().timestamp() * 1000),
|
||||
device_info=request.form.get('device'),
|
||||
sim_info=request.form.get('sim'),
|
||||
sign_verified=sign_verified,
|
||||
ip_address=ip_address
|
||||
)
|
||||
|
||||
# 记录成功日志
|
||||
db.add_log(from_number, content, timestamp, sign, sign_verified, ip_address,
|
||||
'success')
|
||||
|
||||
app.logger.info(f'收到短信: {from_number} -> {content[:50]}... (ID: {message_id})')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message_id': message_id,
|
||||
'message': '短信已接收'
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f'处理短信失败: {e}', exc_info=True)
|
||||
return jsonify({'error': '服务器内部错误'}), 500
|
||||
|
||||
@app.route('/api/messages', methods=['GET'])
|
||||
@login_required
|
||||
def api_messages():
|
||||
"""短信列表 API"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
limit = request.args.get('limit', 20, type=int)
|
||||
from_number = request.args.get('from', None)
|
||||
search = request.args.get('search', None)
|
||||
|
||||
messages = db.get_messages(page, limit, from_number, search)
|
||||
total = db.get_message_count(from_number, search)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': messages,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit
|
||||
})
|
||||
|
||||
@app.route('/api/statistics', methods=['GET'])
|
||||
@login_required
|
||||
def api_statistics():
|
||||
"""统计信息 API"""
|
||||
stats = db.get_statistics()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': stats
|
||||
})
|
||||
|
||||
@app.route('/cleanup')
|
||||
@login_required
|
||||
def cleanup():
|
||||
"""清理老数据"""
|
||||
deleted = db.cleanup_old_messages(
|
||||
days=app.config['CLEANUP_DAYS'],
|
||||
max_messages=app.config['MAX_MESSAGES']
|
||||
)
|
||||
flash(f'已清理 {deleted} 条旧数据', 'success')
|
||||
return redirect(url_for('statistics'))
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
return render_template('error.html', error='页面不存在'), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def server_error(e):
|
||||
app.logger.error(f'服务器错误: {e}', exc_info=True)
|
||||
return render_template('error.html', error='服务器内部错误'), 500
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
env = os.environ.get('FLASK_ENV', 'development')
|
||||
app = create_app(env)
|
||||
|
||||
app.logger.info(f'启动短信接收服务 (环境: {env})')
|
||||
app.logger.info(f'数据库: {app.config["DATABASE_PATH"]}')
|
||||
app.logger.info(f'监听端口: {app.config["PORT"]}')
|
||||
app.logger.info(f'登录已启用: {app.config["LOGIN_ENABLED"]}')
|
||||
|
||||
app.run(
|
||||
host=app.config['HOST'],
|
||||
port=app.config['PORT'],
|
||||
debug=app.config['DEBUG']
|
||||
)
|
||||
37
config.json
Normal file
37
config.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "短信转发接收端",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 9518,
|
||||
"debug": true
|
||||
},
|
||||
"security": {
|
||||
"enabled": true,
|
||||
"username": "admin",
|
||||
"password": "admin123",
|
||||
"session_lifetime": 3600,
|
||||
"secret_key": "default_secret_key_change_me",
|
||||
"sign_verify": true,
|
||||
"sign_max_age": 3600000
|
||||
},
|
||||
"sms": {
|
||||
"max_messages": 10000,
|
||||
"auto_cleanup": true,
|
||||
"cleanup_days": 30
|
||||
},
|
||||
"database": {
|
||||
"path": "sms_receiver.db"
|
||||
},
|
||||
"timezone": "Asia/Shanghai",
|
||||
"api_tokens": [
|
||||
{
|
||||
"name": "默认配置",
|
||||
"token": "default_token",
|
||||
"secret": "",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
123
config.py
Normal file
123
config.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
短信转发接收端配置文件
|
||||
支持从 config.json 加载配置
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from typing import Dict, List, Any
|
||||
|
||||
|
||||
class Config:
|
||||
"""基础配置"""
|
||||
# 服务器配置
|
||||
HOST = '0.0.0.0'
|
||||
PORT = 9518
|
||||
DEBUG = False
|
||||
|
||||
# 安全配置
|
||||
SECRET_KEY = 'default_secret_key_change_me'
|
||||
SIGN_VERIFY = True
|
||||
SIGN_MAX_AGE = 3600000 # 签名最大有效时间(毫秒),默认1小时
|
||||
|
||||
# 登录配置
|
||||
LOGIN_ENABLED = True
|
||||
LOGIN_USERNAME = 'admin'
|
||||
LOGIN_PASSWORD = 'admin123'
|
||||
SESSION_LIFETIME = 3600 # 会话有效期(秒),默认1小时
|
||||
|
||||
# 数据库配置
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DATABASE_PATH = os.path.join(BASE_DIR, 'sms_receiver.db')
|
||||
|
||||
# 短信存储配置
|
||||
MAX_MESSAGES = 10000
|
||||
AUTO_CLEANUP = True
|
||||
CLEANUP_DAYS = 30
|
||||
|
||||
# Web界面配置
|
||||
PER_PAGE = 50
|
||||
REFRESH_INTERVAL = 30
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL = 'INFO'
|
||||
LOG_FILE = 'sms_receiver.log'
|
||||
|
||||
# 时区配置
|
||||
TIMEZONE = 'Asia/Shanghai'
|
||||
|
||||
# API Token 配置
|
||||
API_TOKENS: List[Dict[str, Any]] = []
|
||||
|
||||
@classmethod
|
||||
def load_from_json(cls, json_path: str = 'config.json') -> 'Config':
|
||||
"""从 JSON 文件加载配置"""
|
||||
config_obj = cls()
|
||||
|
||||
if os.path.exists(json_path):
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
config_data = json.load(f)
|
||||
|
||||
# 服务器配置
|
||||
if 'server' in config_data:
|
||||
server = config_data['server']
|
||||
config_obj.HOST = server.get('host', config_obj.HOST)
|
||||
config_obj.PORT = server.get('port', config_obj.PORT)
|
||||
config_obj.DEBUG = server.get('debug', config_obj.DEBUG)
|
||||
|
||||
# 安全配置
|
||||
if 'security' in config_data:
|
||||
security = config_data['security']
|
||||
config_obj.LOGIN_ENABLED = security.get('enabled', config_obj.LOGIN_ENABLED)
|
||||
config_obj.LOGIN_USERNAME = security.get('username', config_obj.LOGIN_USERNAME)
|
||||
config_obj.LOGIN_PASSWORD = security.get('password', config_obj.LOGIN_PASSWORD)
|
||||
config_obj.SESSION_LIFETIME = security.get('session_lifetime', config_obj.SESSION_LIFETIME)
|
||||
config_obj.SECRET_KEY = security.get('secret_key', config_obj.SECRET_KEY)
|
||||
config_obj.SIGN_VERIFY = security.get('sign_verify', config_obj.SIGN_VERIFY)
|
||||
config_obj.SIGN_MAX_AGE = security.get('sign_max_age', config_obj.SIGN_MAX_AGE)
|
||||
|
||||
# 短信配置
|
||||
if 'sms' in config_data:
|
||||
sms = config_data['sms']
|
||||
config_obj.MAX_MESSAGES = sms.get('max_messages', config_obj.MAX_MESSAGES)
|
||||
config_obj.AUTO_CLEANUP = sms.get('auto_cleanup', config_obj.AUTO_CLEANUP)
|
||||
config_obj.CLEANUP_DAYS = sms.get('cleanup_days', config_obj.CLEANUP_DAYS)
|
||||
|
||||
# 数据库配置
|
||||
if 'database' in config_data:
|
||||
database = config_data['database']
|
||||
if 'path' in database:
|
||||
# 如果是绝对路径,直接使用;如果是相对路径,相对于项目目录
|
||||
db_path = database['path']
|
||||
if not os.path.isabs(db_path):
|
||||
db_path = os.path.join(config_obj.BASE_DIR, db_path)
|
||||
config_obj.DATABASE_PATH = db_path
|
||||
|
||||
# 时区配置
|
||||
if 'timezone' in config_data:
|
||||
config_obj.TIMEZONE = config_data['timezone']
|
||||
|
||||
# API Token 配置
|
||||
if 'api_tokens' in config_data:
|
||||
config_obj.API_TOKENS = config_data['api_tokens']
|
||||
|
||||
return config_obj
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""开发环境配置"""
|
||||
DEBUG = True
|
||||
LOG_LEVEL = 'DEBUG'
|
||||
|
||||
|
||||
class ProductionConfig(Config):
|
||||
"""生产环境配置"""
|
||||
DEBUG = False
|
||||
|
||||
|
||||
# 配置映射
|
||||
config = {
|
||||
'development': DevelopmentConfig,
|
||||
'production': ProductionConfig,
|
||||
'default': DevelopmentConfig
|
||||
}
|
||||
353
database.py
Normal file
353
database.py
Normal file
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
数据库模型
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional, Dict, Any
|
||||
import os
|
||||
|
||||
|
||||
class Database:
|
||||
"""数据库管理类"""
|
||||
|
||||
def __init__(self, db_path: str, timezone_offset: int = 8):
|
||||
"""初始化数据库
|
||||
|
||||
Args:
|
||||
db_path: 数据库文件路径
|
||||
timezone_offset: 时区偏移(小时),默认 UTC+8
|
||||
"""
|
||||
self.db_path = db_path
|
||||
self.timezone_offset = timezone_offset
|
||||
self.init_db()
|
||||
|
||||
def convert_to_local(self, dt_str: Optional[str]) -> Optional[str]:
|
||||
"""将 UTC 时间字符串转换为本地时间字符串
|
||||
|
||||
Args:
|
||||
dt_str: UTC 时间字符串 (格式: 2024-01-01 00:00:00)
|
||||
|
||||
Returns:
|
||||
本地时间字符串
|
||||
"""
|
||||
if not dt_str:
|
||||
return None
|
||||
|
||||
try:
|
||||
# 解析 UTC 时间
|
||||
utc_dt = datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S')
|
||||
# 添加时区
|
||||
utc_dt = utc_dt.replace(tzinfo=timezone.utc)
|
||||
# 转换为本地时间
|
||||
local_dt = utc_dt + timedelta(hours=self.timezone_offset)
|
||||
return local_dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
except Exception:
|
||||
return dt_str
|
||||
|
||||
def get_connection(self):
|
||||
"""获取数据库连接"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def init_db(self):
|
||||
"""初始化数据库表"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 创建短信表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS sms_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_number TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
device_info TEXT,
|
||||
sim_info TEXT,
|
||||
sign_verified INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
ip_address TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
# 创建接收日志表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS receive_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_number TEXT,
|
||||
content TEXT,
|
||||
timestamp INTEGER,
|
||||
sign TEXT,
|
||||
sign_valid INTEGER,
|
||||
ip_address TEXT,
|
||||
status TEXT NOT NULL,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# 创建索引
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_from_number ON sms_messages(from_number)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_timestamp ON sms_messages(timestamp)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_created_at ON sms_messages(created_at)')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def add_message(self, from_number: str, content: str, timestamp: int,
|
||||
device_info: Optional[str] = None, sim_info: Optional[str] = None,
|
||||
sign_verified: bool = False, ip_address: Optional[str] = None) -> int:
|
||||
"""添加短信"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 存储为 UTC 时间
|
||||
utc_now = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
|
||||
cursor.execute('''
|
||||
INSERT INTO sms_messages
|
||||
(from_number, content, timestamp, device_info, sim_info, sign_verified, ip_address, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (from_number, content, timestamp, device_info, sim_info, sign_verified, ip_address, utc_now))
|
||||
|
||||
message_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return message_id
|
||||
|
||||
def add_log(self, from_number: Optional[str], content: Optional[str],
|
||||
timestamp: Optional[int], sign: Optional[str],
|
||||
sign_valid: Optional[bool], ip_address: Optional[str],
|
||||
status: str, error_message: Optional[str] = None) -> int:
|
||||
"""添加接收日志"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
sign_valid_int = 1 if sign_valid else 0 if sign_valid is not None else None
|
||||
|
||||
# 存储为 UTC 时间
|
||||
utc_now = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
|
||||
cursor.execute('''
|
||||
INSERT INTO receive_logs
|
||||
(from_number, content, timestamp, sign, sign_valid, ip_address, status, error_message, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (from_number, content, timestamp, sign, sign_valid_int, ip_address, status, error_message, utc_now))
|
||||
|
||||
log_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return log_id
|
||||
|
||||
def get_messages(self, page: int = 1, limit: int = 50,
|
||||
from_number: Optional[str] = None,
|
||||
search: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""获取短信列表"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
offset = (page - 1) * limit
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
if from_number:
|
||||
where_clauses.append("from_number = ?")
|
||||
params.append(from_number)
|
||||
|
||||
if search:
|
||||
where_clauses.append("(content LIKE ? OR from_number LIKE ?)")
|
||||
params.extend([f"%{search}%", f"%{search}%"])
|
||||
|
||||
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
|
||||
|
||||
query = f'''
|
||||
SELECT * FROM sms_messages
|
||||
WHERE {where_sql}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ? OFFSET ?
|
||||
'''
|
||||
params.extend([limit, offset])
|
||||
|
||||
cursor.execute(query, params)
|
||||
messages = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
# 转换时间为本地时区
|
||||
for msg in messages:
|
||||
if msg.get('created_at'):
|
||||
msg['created_at'] = self.convert_to_local(msg['created_at'])
|
||||
# 转换时间戳为本地时间
|
||||
if msg.get('timestamp'):
|
||||
timestamp = msg['timestamp']
|
||||
local_dt = datetime.fromtimestamp(timestamp / 1000, timezone(timedelta(hours=self.timezone_offset)))
|
||||
msg['local_timestamp'] = local_dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
conn.close()
|
||||
|
||||
return messages
|
||||
|
||||
def get_message_count(self, from_number: Optional[str] = None,
|
||||
search: Optional[str] = None) -> int:
|
||||
"""获取短信总数"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
if from_number:
|
||||
where_clauses.append("from_number = ?")
|
||||
params.append(from_number)
|
||||
|
||||
if search:
|
||||
where_clauses.append("(content LIKE ? OR from_number LIKE ?)")
|
||||
params.extend([f"%{search}%", f"%{search}%"])
|
||||
|
||||
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
|
||||
|
||||
cursor.execute(f'SELECT COUNT(*) as count FROM sms_messages WHERE {where_sql}', params)
|
||||
count = cursor.fetchone()['count']
|
||||
conn.close()
|
||||
|
||||
return count
|
||||
|
||||
def get_message_by_id(self, message_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""根据ID获取短信"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('SELECT * FROM sms_messages WHERE id = ?', (message_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if row:
|
||||
msg = dict(row)
|
||||
# 转换时间为本地时区
|
||||
if msg.get('created_at'):
|
||||
msg['created_at'] = self.convert_to_local(msg['created_at'])
|
||||
# 转换时间戳为本地时间
|
||||
if msg.get('timestamp'):
|
||||
timestamp = msg['timestamp']
|
||||
local_dt = datetime.fromtimestamp(timestamp / 1000, timezone(timedelta(hours=self.timezone_offset)))
|
||||
msg['local_timestamp'] = local_dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
return msg
|
||||
return None
|
||||
|
||||
def get_recent_messages(self, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""获取最近的短信"""
|
||||
return self.get_messages(page=1, limit=limit)
|
||||
|
||||
def get_from_numbers(self) -> List[Dict[str, Any]]:
|
||||
"""获取所有发送方号码"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT from_number, COUNT(*) as count, MAX(timestamp) as last_time
|
||||
FROM sms_messages
|
||||
GROUP BY from_number
|
||||
ORDER BY count DESC
|
||||
''')
|
||||
|
||||
numbers = [dict(row) for row in cursor.fetchall()]
|
||||
conn.close()
|
||||
|
||||
return numbers
|
||||
|
||||
def get_logs(self, page: int = 1, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""获取接收日志"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
offset = (page - 1) * limit
|
||||
|
||||
cursor.execute('''
|
||||
SELECT * FROM receive_logs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
''', (limit, offset))
|
||||
|
||||
logs = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
# 转换时间为本地时区
|
||||
for log in logs:
|
||||
if log.get('created_at'):
|
||||
log['created_at'] = self.convert_to_local(log['created_at'])
|
||||
|
||||
conn.close()
|
||||
|
||||
return logs
|
||||
|
||||
def cleanup_old_messages(self, days: int = 30, max_messages: int = 10000):
|
||||
"""清理老数据"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 按天数清理
|
||||
cutoff_date = datetime.now() - timedelta(days=days)
|
||||
cursor.execute('''
|
||||
DELETE FROM sms_messages
|
||||
WHERE created_at < ?
|
||||
''', (cutoff_date.strftime('%Y-%m-%d %H:%M:%S'),))
|
||||
|
||||
deleted_count = cursor.rowcount
|
||||
|
||||
# 按数量清理
|
||||
cursor.execute('''
|
||||
DELETE FROM sms_messages
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM sms_messages
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
)
|
||||
''', (max_messages,))
|
||||
|
||||
deleted_count += cursor.rowcount
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return deleted_count
|
||||
|
||||
def get_statistics(self) -> Dict[str, Any]:
|
||||
"""获取统计信息"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 总短信数
|
||||
cursor.execute('SELECT COUNT(*) as total FROM sms_messages')
|
||||
total = cursor.fetchone()['total']
|
||||
|
||||
# 今日短信数
|
||||
cursor.execute('''
|
||||
SELECT COUNT(*) as today FROM sms_messages
|
||||
WHERE DATE(created_at) = DATE('now')
|
||||
''')
|
||||
today = cursor.fetchone()['today']
|
||||
|
||||
# 本周短信数
|
||||
cursor.execute('''
|
||||
SELECT COUNT(*) as week FROM sms_messages
|
||||
WHERE created_at >= datetime('now', '-7 days')
|
||||
''')
|
||||
week = cursor.fetchone()['week']
|
||||
|
||||
# 签名验证占比
|
||||
cursor.execute('''
|
||||
SELECT
|
||||
SUM(CASE WHEN sign_verified = 1 THEN 1 ELSE 0 END) as verified,
|
||||
SUM(CASE WHEN sign_verified = 0 THEN 1 ELSE 0 END) as unverified
|
||||
FROM sms_messages
|
||||
''')
|
||||
row = cursor.fetchone()
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
'total': total,
|
||||
'today': today,
|
||||
'week': week,
|
||||
'verified': row['verified'] or 0,
|
||||
'unverified': row['unverified'] or 0
|
||||
}
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
Flask==3.0.0
|
||||
118
sign_verify.py
Normal file
118
sign_verify.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
签名验证工具
|
||||
参考 TranspondSms 的签名规则:
|
||||
timestamp + "\n" + secret -> HMAC-SHA256 -> Base64 -> URL Encode
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import hashlib
|
||||
import base64
|
||||
import urllib.parse
|
||||
import time
|
||||
|
||||
|
||||
def generate_sign(secret: str, timestamp: int = None) -> str:
|
||||
"""
|
||||
生成签名
|
||||
|
||||
Args:
|
||||
secret: 密钥
|
||||
timestamp: 时间戳(毫秒),不传则使用当前时间
|
||||
|
||||
Returns:
|
||||
签名字符串
|
||||
"""
|
||||
if timestamp is None:
|
||||
timestamp = int(time.time() * 1000)
|
||||
|
||||
string_to_sign = f"{timestamp}\n{secret}"
|
||||
hmac_code = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
string_to_sign.encode('utf-8'),
|
||||
digestmod=hashlib.sha256
|
||||
).digest()
|
||||
sign = urllib.parse.quote(base64.b64encode(hmac_code).decode())
|
||||
|
||||
return sign
|
||||
|
||||
|
||||
def verify_sign(secret: str, sign: str, timestamp: int, max_age: int = 3600000) -> tuple[bool, str]:
|
||||
"""
|
||||
验证签名
|
||||
|
||||
Args:
|
||||
secret: 密钥
|
||||
sign: 待验证的签名
|
||||
timestamp: 时间戳(毫秒)
|
||||
max_age: 签名最大有效时间(毫秒),默认1小时
|
||||
|
||||
Returns:
|
||||
(是否有效, 错误信息)
|
||||
"""
|
||||
current_time = int(time.time() * 1000)
|
||||
|
||||
# 检查时间戳是否过期
|
||||
if abs(current_time - timestamp) > max_age:
|
||||
return False, f"签名过期,时间差: {abs(current_time - timestamp) / 1000:.1f}秒"
|
||||
|
||||
# 生成期望的签名
|
||||
expected_sign = generate_sign(secret, timestamp)
|
||||
|
||||
# 比较签名
|
||||
if sign != expected_sign:
|
||||
return False, "签名不匹配"
|
||||
|
||||
return True, "签名有效"
|
||||
|
||||
|
||||
def verify_from_app(from_number: str, content: str, timestamp: int,
|
||||
sign: str, secret: str, max_age: int = 3600000) -> tuple[bool, str]:
|
||||
"""
|
||||
验证 TranspondSms APP 发来的请求
|
||||
|
||||
Args:
|
||||
from_number: 发送方手机号
|
||||
content: 短信内容
|
||||
timestamp: 时间戳
|
||||
sign: 签名
|
||||
secret: 密钥
|
||||
max_age: 最大有效时间(毫秒)
|
||||
|
||||
Returns:
|
||||
(是否有效, 错误信息)
|
||||
"""
|
||||
# 检查必填字段
|
||||
if not from_number or not content:
|
||||
return False, "缺少必填字段"
|
||||
|
||||
# 如果没有签名,跳过验证(取决于配置)
|
||||
if not sign:
|
||||
return True, "无签名,跳过验证"
|
||||
|
||||
return verify_sign(secret, sign, timestamp, max_age)
|
||||
|
||||
|
||||
# 测试代码
|
||||
if __name__ == '__main__':
|
||||
# 测试签名生成和验证
|
||||
secret = "test_secret"
|
||||
timestamp = int(time.time() * 1000)
|
||||
|
||||
# 生成签名
|
||||
sign = generate_sign(secret, timestamp)
|
||||
print(f"Timestamp: {timestamp}")
|
||||
print(f"Sign: {sign}")
|
||||
|
||||
# 验证签名
|
||||
is_valid, message = verify_sign(secret, sign, timestamp)
|
||||
print(f"验证结果: {is_valid}, {message}")
|
||||
|
||||
# 测试过期签名
|
||||
old_timestamp = timestamp - 7200000 # 2小时前
|
||||
is_valid, message = verify_sign(secret, sign, old_timestamp)
|
||||
print(f"过期验证: {is_valid}, {message}")
|
||||
|
||||
# 测试错误签名
|
||||
wrong_sign = "wrong_signature"
|
||||
is_valid, message = verify_sign(secret, wrong_sign, timestamp)
|
||||
print(f"错误签名: {is_valid}, {message}")
|
||||
68
start.sh
Executable file
68
start.sh
Executable file
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 短信转发接收端启动脚本
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 设置项目目录
|
||||
PROJECT_DIR="/root/.openclaw/workspace/sms-receiver"
|
||||
cd "$PROJECT_DIR" || exit 1
|
||||
|
||||
# 检查 Python
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
echo -e "${RED}错误: 未找到 Python3${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查依赖
|
||||
echo -e "${YELLOW}检查依赖...${NC}"
|
||||
|
||||
# 创建虚拟环境(如果不存在)
|
||||
if [ ! -d "venv" ]; then
|
||||
echo -e "${YELLOW}创建虚拟环境...${NC}"
|
||||
python3 -m venv venv
|
||||
fi
|
||||
|
||||
# 激活虚拟环境
|
||||
source venv/bin/activate
|
||||
|
||||
# 安装依赖
|
||||
pip install -q -r requirements.txt
|
||||
|
||||
# 配置文件检查
|
||||
if [ ! -f ".env" ]; then
|
||||
echo -e "${YELLOW}创建配置文件...${NC}"
|
||||
cat > .env << EOF
|
||||
# 短信转发接收端配置
|
||||
FLASK_ENV=development
|
||||
SMS_SECRET_KEY=default_secret_key_change_me
|
||||
EOF
|
||||
echo -e "${YELLOW}请修改 .env 文件中的 SMS_SECRET_KEY${NC}"
|
||||
fi
|
||||
|
||||
# 启动服务
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN}短信转发接收端${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo -e "服务地址: ${GREEN}http://127.0.0.1:9518${NC}"
|
||||
echo -e "接收API: ${GREEN}http://127.0.0.1:9518/api/receive${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}在 TranspondSms APP 中配置:${NC}"
|
||||
echo -e " Token (URL): ${GREEN}http://your-server-ip:9518/api/receive${NC}"
|
||||
echo -e " Secret: ${GREEN}default_secret_key_change_me${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}按 Ctrl+C 停止服务${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# 设置环境变量
|
||||
export FLASK_ENV=$(grep "FLASK_ENV" .env | cut -d '=' -f2)
|
||||
export SMS_SECRET_KEY=$(grep "SMS_SECRET_KEY" .env | cut -d '=' -f2)
|
||||
|
||||
# 启动应用
|
||||
python3 app.py
|
||||
73
templates/error.html
Normal file
73
templates/error.html
Normal file
@@ -0,0 +1,73 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>错误</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.error-container h1 {
|
||||
font-size: 28px;
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.error-container p {
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.error-container a {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.error-container a:hover {
|
||||
background: #764ba2;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<h1>{{ error or '出错了' }}</h1>
|
||||
<p>抱歉,遇到了一些问题,请稍后再试。</p>
|
||||
<a href="/">返回首页</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
490
templates/index.html
Normal file
490
templates/index.html
Normal file
@@ -0,0 +1,490 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>短信转发接收端</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
padding: 8px 16px;
|
||||
text-decoration: none;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-radius: 5px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.nav a:hover {
|
||||
background: #764ba2;
|
||||
}
|
||||
|
||||
.nav .logout {
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
.nav .logout:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar input,
|
||||
.toolbar select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
padding: 8px 16px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.toolbar button:hover {
|
||||
background: #764ba2;
|
||||
}
|
||||
|
||||
.toolbar .refresh-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.messages-table {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.messages-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.messages-table th,
|
||||
.messages-table td {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.messages-table th {
|
||||
background: #f5f5f5;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.messages-table tr:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.from-number {
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.sign-verified {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.sign-verified.yes {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.sign-verified.no {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pagination a,
|
||||
.pagination span {
|
||||
padding: 8px 12px;
|
||||
background: white;
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.pagination a:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pagination span.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.from-numbers-filter {
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.from-numbers-filter h3 {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.from-numbers-filter .tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.from-numbers-filter .tag {
|
||||
padding: 5px 10px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.from-numbers-filter .tag:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.auto-refresh {
|
||||
margin-left: 20px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.refresh-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 8px 12px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.refresh-toggle.active {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.refresh-toggle input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.toolbar .refresh-btn {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📱 短信转发接收端</h1>
|
||||
<div class="nav">
|
||||
<a href="/">短信列表</a>
|
||||
<a href="/logs">接收日志</a>
|
||||
<a href="/statistics">统计信息</a>
|
||||
<a href="/logout" class="logout">退出登录</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<h3>短信总数</h3>
|
||||
<div class="value">{{ stats.total }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>今日</h3>
|
||||
<div class="value">{{ stats.today }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>本周</h3>
|
||||
<div class="value">{{ stats.week }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>签名验证</h3>
|
||||
<div class="value">
|
||||
{{ stats.verified }} / {{ stats.verified + stats.unverified }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="from-numbers-filter">
|
||||
<h3>快捷筛选 (按号码)</h3>
|
||||
<div class="tags">
|
||||
{% for num in from_numbers %}
|
||||
<span class="tag" onclick="filterByNumber('{{ num.from_number }}')">
|
||||
{{ num.from_number }} ({{ num.count }})
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<input type="text" id="searchInput" placeholder="搜索内容或号码..." value="{{ search }}">
|
||||
<button onclick="search()">搜索</button>
|
||||
<button onclick="clearSearch()">清除</button>
|
||||
<span class="auto-refresh">
|
||||
<label class="refresh-toggle" id="refreshToggle">
|
||||
<input type="checkbox" id="autoRefresh" checked>
|
||||
<span>自动刷新</span>
|
||||
</label>
|
||||
<span id="refreshCountdown">{{ refresh_interval }}s</span>
|
||||
</span>
|
||||
<div class="refresh-btn">
|
||||
<button onclick="location.reload()">立即刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="messages-table">
|
||||
{% if messages %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>发送方</th>
|
||||
<th>内容</th>
|
||||
<th>时间</th>
|
||||
<th>验证</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for msg in messages %}
|
||||
<tr>
|
||||
<td>{{ msg.id }}</td>
|
||||
<td><span class="from-number">{{ msg.from_number }}</span></td>
|
||||
<td>
|
||||
<a href="/message/{{ msg.id }}" style="color: #333; text-decoration: none;">
|
||||
{{ msg.content[:50] }}{% if msg.content|length > 50 %}...{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ msg.created_at }}</td>
|
||||
<td>
|
||||
{% if msg.sign_verified %}
|
||||
<span class="sign-verified yes">✓ 已验证</span>
|
||||
{% else %}
|
||||
<span class="sign-verified no">✗ 未验证</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<h3>暂无短信数据</h3>
|
||||
<p>等待接收短信...</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if total_pages > 1 %}
|
||||
<div class="pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page - 1 }}&from={{ from_number }}&search={{ search }}&limit={{ limit }}">上一页</a>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<span class="active">{{ p }}</span>
|
||||
{% elif p <= 3 or p >= total_pages - 2 or (p >= page - 1 and p <= page + 1) %}
|
||||
<a href="?page={{ p }}&from={{ from_number }}&search={{ search }}&limit={{ limit }}">{{ p }}</a>
|
||||
{% elif p == 4 or p == total_pages - 3 %}
|
||||
<span>...</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<a href="?page={{ page + 1 }}&from={{ from_number }}&search={{ search }}&limit={{ limit }}">下一页</a>
|
||||
{% endif %}
|
||||
|
||||
<span>共 {{ total }} 条,第 {{ page }} / {{ total_pages }} 页</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let refreshInterval;
|
||||
let countdownInterval;
|
||||
let refreshCountdown = {{ refresh_interval }};
|
||||
|
||||
function search() {
|
||||
const query = document.getElementById('searchInput').value;
|
||||
window.location.href = `/?search=${encodeURIComponent(query)}&from={{ from_number }}&limit={{ limit }}`;
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
function filterByNumber(number) {
|
||||
window.location.href = `/?from=${encodeURIComponent(number)}&limit={{ limit }}`;
|
||||
}
|
||||
|
||||
function autoRefresh() {
|
||||
const autoRefreshCheckbox = document.getElementById('autoRefresh');
|
||||
const refreshToggle = document.getElementById('refreshToggle');
|
||||
|
||||
if (autoRefreshCheckbox.checked) {
|
||||
refreshToggle.classList.add('active');
|
||||
startAutoRefresh();
|
||||
} else {
|
||||
refreshToggle.classList.remove('active');
|
||||
stopAutoRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
refreshCountdown = {{ refresh_interval }};
|
||||
updateCountdown();
|
||||
|
||||
refreshInterval = setInterval(() => {
|
||||
location.reload();
|
||||
}, {{ refresh_interval * 1000 }});
|
||||
|
||||
countdownInterval = setInterval(() => {
|
||||
refreshCountdown--;
|
||||
updateCountdown();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
clearInterval(refreshInterval);
|
||||
clearInterval(countdownInterval);
|
||||
document.getElementById('refreshCountdown').textContent = '--s';
|
||||
}
|
||||
|
||||
function updateCountdown() {
|
||||
document.getElementById('refreshCountdown').textContent = `${refreshCountdown}s`;
|
||||
}
|
||||
|
||||
// Enter 键搜索
|
||||
document.getElementById('searchInput').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
search();
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化自动刷新
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('autoRefresh').addEventListener('change', autoRefresh);
|
||||
startAutoRefresh();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
156
templates/login.html
Normal file
156
templates/login.html
Normal file
@@ -0,0 +1,156 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 - 短信转发接收端</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
background: #764ba2;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.alert.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.alert.info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
|
||||
.flash-messages {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-container {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="login-header">
|
||||
<h1>📱 短信转发接收端</h1>
|
||||
<p>请登录以继续</p>
|
||||
</div>
|
||||
|
||||
<div class="flash-messages">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert {{ category }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('login', next=request.args.get('next')) }}">
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<input type="text" id="username" name="username" placeholder="请输入用户名" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<input type="password" id="password" name="password" placeholder="请输入密码" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="login-btn">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
195
templates/logs.html
Normal file
195
templates/logs.html
Normal file
@@ -0,0 +1,195 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>接收日志</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.header .nav a {
|
||||
padding: 8px 16px;
|
||||
text-decoration: none;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-radius: 5px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.header .nav a:hover {
|
||||
background: #764ba2;
|
||||
}
|
||||
|
||||
.logs-table {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.logs-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.logs-table th,
|
||||
.logs-table td {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.logs-table th {
|
||||
background: #f5f5f5;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.logs-table tr:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.sign-valid {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sign-valid.yes {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.sign-valid.no {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logs-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📋 接收日志</h1>
|
||||
<div class="nav">
|
||||
<a href="/">返回列表</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logs-table">
|
||||
{% if logs %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>发送方</th>
|
||||
<th>状态</th>
|
||||
<th>签名</th>
|
||||
<th>IP 地址</th>
|
||||
<th>错误信息</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td>{{ log.created_at }}</td>
|
||||
<td>{{ log.from_number or '-' }}</td>
|
||||
<td>
|
||||
<span class="status {{ log.status }}">
|
||||
{% if log.status == 'success' %}✓ 成功{% else %}✗ 失败{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if log.sign_valid is not none %}
|
||||
{% if log.sign_valid %}
|
||||
<span class="sign-valid yes">✓</span>
|
||||
{% else %}
|
||||
<span class="sign-valid no">✗</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ log.ip_address or '-' }}</td>
|
||||
<td>{{ log.error_message or '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<h3>暂无日志数据</h3>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
225
templates/message_detail.html
Normal file
225
templates/message_detail.html
Normal file
@@ -0,0 +1,225 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>短信详情 #{{ message.id }}</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-header h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.card-header .nav {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-header a {
|
||||
padding: 8px 16px;
|
||||
text-decoration: none;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border-radius: 5px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.card-header a:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.field .value {
|
||||
padding: 15px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
color: #333;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.field .value.from-number {
|
||||
font-size: 18px;
|
||||
color: #667eea;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.field .value.content {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.field .value.timestamp {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.meta .field .value {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sign-verified {
|
||||
display: inline-block;
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.sign-verified.yes {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.sign-verified.no {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h1>📱 短信详情 #{{ message.id }}</h1>
|
||||
<div class="nav">
|
||||
<a href="/">返回列表</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>发送方号码</label>
|
||||
<div class="value from-number">{{ message.from_number }}</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>短信内容</label>
|
||||
<div class="value content">{{ message.content }}</div>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<div class="field">
|
||||
<label>接收时间</label>
|
||||
<div class="value timestamp">{{ message.created_at }}</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>原始时间戳</label>
|
||||
<div class="value timestamp">
|
||||
{% if message.timestamp %}
|
||||
{{ message.timestamp }} ms
|
||||
{% else %}
|
||||
N/A
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>签名验证</label>
|
||||
<div class="value">
|
||||
{% if message.sign_verified %}
|
||||
<span class="sign-verified yes">✓ 已验证</span>
|
||||
{% else %}
|
||||
<span class="sign-verified no">✗ 未验证</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>来源 IP</label>
|
||||
<div class="value">
|
||||
{% if message.ip_address %}
|
||||
{{ message.ip_address }}
|
||||
{% else %}
|
||||
N/A
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if message.device_info or message.sim_info %}
|
||||
<div class="meta" style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #eee;">
|
||||
{% if message.device_info %}
|
||||
<div class="field">
|
||||
<label>设备信息</label>
|
||||
<div class="value">{{ message.device_info }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if message.sim_info %}
|
||||
<div class="field">
|
||||
<label>SIM 卡信息</label>
|
||||
<div class="value">{{ message.sim_info }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
257
templates/statistics.html
Normal file
257
templates/statistics.html
Normal file
@@ -0,0 +1,257 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>统计信息</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 20px;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.header .nav a {
|
||||
padding: 8px 16px;
|
||||
text-decoration: none;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-card h2 {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-card .sub {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
border-bottom: 2px solid #667eea;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.recent-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.recent-list li {
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.recent-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.recent-list .from {
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.recent-list .time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.recent-list .content {
|
||||
color: #333;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.numbers-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.number-card {
|
||||
padding: 15px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.number-card .number {
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.number-card .count {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cleanup-btn {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.cleanup-btn:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📊 统计信息</h1>
|
||||
<div class="nav">
|
||||
<a href="/">返回列表</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<h2>短信总数</h2>
|
||||
<div class="value">{{ stats.total }}</div>
|
||||
<div class="sub">累计接收</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h2>今日</h2>
|
||||
<div class="value">{{ stats.today }}</div>
|
||||
<div class="sub">今日新增</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h2>本周</h2>
|
||||
<div class="value">{{ stats.week }}</div>
|
||||
<div class="sub">近7天</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h2>签名验证</h2>
|
||||
<div class="value">{{ stats.verified }} / {{ stats.verified + stats.unverified }}</div>
|
||||
<div class="sub">已验证 / 总数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>📬 最近接收</h3>
|
||||
{% if recent %}
|
||||
<ul class="recent-list">
|
||||
{% for msg in recent %}
|
||||
<li>
|
||||
<span class="from">{{ msg.from_number }}</span>
|
||||
<span class="time">{{ msg.created_at }}</span>
|
||||
<div class="content">{{ msg.content[:100] }}{% if msg.content|length > 100 %}...{% endif %}</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
暂无数据
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>📱 发送方号码</h3>
|
||||
{% if from_numbers %}
|
||||
<div class="numbers-list">
|
||||
{% for num in from_numbers %}
|
||||
<div class="number-card">
|
||||
<div class="number">{{ num.from_number }}</div>
|
||||
<div class="count">{{ num.count }} 条短信</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
暂无数据
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>🗑️ 数据清理</h3>
|
||||
<p style="color: #666; margin-bottom: 15px;">
|
||||
清理 {{ cleanup_days }} 天前的数据,最多保留 {{ max_messages }} 条
|
||||
</p>
|
||||
<button class="cleanup-btn" onclick="if(confirm('确定要清理旧数据吗?此操作不可恢复!')) { location.href='/cleanup'; }">
|
||||
清理旧数据
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user