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:
OpenClaw Agent
2026-02-06 23:23:49 +00:00
commit 4e5e93660d
16 changed files with 3754 additions and 0 deletions

47
.gitignore vendored Normal file
View 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
View 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. 配置 HTTPSLet'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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
Flask==3.0.0

118
sign_verify.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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>

View 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
View 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>