initial commit: Go version of SMS Receiver with fixed template rendering
- Implemented all core features from Python version - Fixed int64/int type compatibility in template functions - Added login authentication, SMS receiving, statistics, logs - Independent database: sms_receiver_go.db - Fixed frontend display issues for message list and statistics
This commit is contained in:
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# 编译的二进制文件
|
||||
sms-receiver
|
||||
sms-receiver-new
|
||||
|
||||
# 数据库文件
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# 日志文件
|
||||
*.log
|
||||
nohup.out
|
||||
|
||||
# 临时文件
|
||||
tmp/
|
||||
*.tmp
|
||||
|
||||
# Go 相关
|
||||
go.sum
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# 备份文件
|
||||
*.bak
|
||||
*.backup
|
||||
186
GO_REFACTOR_PROGRESS.md
Normal file
186
GO_REFACTOR_PROGRESS.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Go 版本短信接收端 - 重构进度报告
|
||||
|
||||
**日期**: 2026-02-08
|
||||
**状态**: 开发中
|
||||
|
||||
## 项目概述
|
||||
|
||||
将 Python 版本短信转发接收端完全用 Go 语言重写,实现独立数据库和全部功能。
|
||||
|
||||
## 当前状态
|
||||
|
||||
### ✅ 已完成
|
||||
|
||||
1. **后端核心功能**
|
||||
- [x] 数据库初始化 (`sms_receiver_go.db` 独立数据库)
|
||||
- [x] 短信接收 API (`POST /api/receive`)
|
||||
- [x] 登录验证 (`/login`, `/logout`)
|
||||
- [x] 短信列表查询 (`GET /`)
|
||||
- [x] 统计信息计算 (`/statistics`)
|
||||
- [x] 接收日志 (`/logs`)
|
||||
- [x] 短信详情 (`/message/{id}`)
|
||||
- [x] 会话管理
|
||||
|
||||
2. **数据库设计**
|
||||
- [x] `sms_messages` 表 (短信存储)
|
||||
- [x] `receive_logs` 表 (接收日志)
|
||||
- [x] 索引优化 (from_number, timestamp, created_at)
|
||||
|
||||
3. **前端模板**
|
||||
- [x] 登录页面 (`login.html`)
|
||||
- [x] 短信列表页面 (`index.html`)
|
||||
- [x] 统计页面 (`statistics.html`)
|
||||
- [x] 日志页面 (`logs.html`)
|
||||
- [x] 详情页面 (`message_detail.html`)
|
||||
|
||||
4. **配置管理**
|
||||
- [x] YAML 配置文件 (`config.yaml`)
|
||||
- [x] API Token 管理
|
||||
- [x] 签名验证配置
|
||||
|
||||
### ⚠️ 待修复问题
|
||||
|
||||
#### 运行时测试结果
|
||||
|
||||
**数据库状态**:
|
||||
```
|
||||
- 数据库文件: sms_receiver_go.db
|
||||
- 表结构: ✅ 正常
|
||||
- 测试数据: 3 条
|
||||
1. TranspondSms test (id: 1)
|
||||
2. 测试号码 (id: 2)
|
||||
3. 10086 (id: 3, 手动插入)
|
||||
```
|
||||
|
||||
**查询测试**:
|
||||
```sql
|
||||
-- 总数查询: ✅ 正常 (返回 3)
|
||||
SELECT COUNT(*) FROM sms_messages;
|
||||
|
||||
-- 今日统计: ✅ 正常 (返回 3)
|
||||
SELECT COUNT(*) FROM sms_messages WHERE date(created_at) = '2026-02-08';
|
||||
|
||||
-- 签名验证统计: ⚠️ 需要验证
|
||||
-- 所有测试数据 sign_verified = 1
|
||||
```
|
||||
|
||||
**前端显示问题**:
|
||||
- 📋 **短信列表页面**: 数据查询正常但未显示 (需要检查模板渲染)
|
||||
- 📊 **统计页面**: 数据返回正常但未正确渲染 (可能 Go 模板语法问题)
|
||||
|
||||
### 🐛 已知问题
|
||||
|
||||
1. **统计页面模板变量访问**
|
||||
- 问题: `{{mulFloat .stats.Verified .stats.Total}}` 模板函数可能未正确注册
|
||||
- 预期: 计算验证通过率百分比
|
||||
|
||||
2. **短信列表渲染**
|
||||
- 问题: `{{range .messages}}` 循环可能未正确执行
|
||||
- 需要检查: handlers 传递给模板的数据结构
|
||||
|
||||
3. **时区处理**
|
||||
- Python 版本使用自定义时区偏移
|
||||
- Go 版本使用 `time.LoadLocation("Asia/Shanghai")`
|
||||
- 需要验证两者时间显示一致
|
||||
|
||||
## 技术对比
|
||||
|
||||
| 功能 | Python 版本 | Go 版本 | 状态 |
|
||||
|------|------------|---------|------|
|
||||
| Web 框架 | Flask | Gorilla Mux | ✅ |
|
||||
| 数据库 | Python sqlite3 | mattn/go-sqlite3 | ✅ |
|
||||
| 模板 | Jinja2 | Go html/template | ✅ |
|
||||
| 会话 | Flask session | Cookie-based | ✅ |
|
||||
| 日志 | Python logging | Go log | ✅ |
|
||||
| 时区 | pytz | time.LoadLocation | ✅ |
|
||||
| 签名验证 | hashlib | HMAC | ✅ |
|
||||
|
||||
## 代码结构
|
||||
|
||||
```
|
||||
SmsReceiver-go/
|
||||
├── main.go # 入口文件
|
||||
├── config.yaml # 配置文件
|
||||
├── sms-receiver # 编译后的二进制
|
||||
├── sms_receiver_go.db # 独立数据库
|
||||
├── auth/
|
||||
│ └── auth.go # 认证逻辑
|
||||
├── config/
|
||||
│ └── config.go # 配置加载
|
||||
├── database/
|
||||
│ └── database.go # 数据库操作
|
||||
├── handlers/
|
||||
│ └── handlers.go # HTTP 处理器
|
||||
├── models/
|
||||
│ └── message.go # 数据模型
|
||||
├── sign/
|
||||
│ └── sign.go # 签名验证
|
||||
├── static/ # 静态资源
|
||||
└── templates/ # HTML 模板
|
||||
```
|
||||
|
||||
## 测试计划
|
||||
|
||||
### 需要测试的功能
|
||||
|
||||
- [ ] 短信接收 API 响应
|
||||
- [ ] 短信列表数据显示
|
||||
- [ ] 统计页面数字显示
|
||||
- [ ] 分页功能
|
||||
- [ ] 搜索筛选
|
||||
- [ ] 时区转换准确性
|
||||
- [ ] 签名验证逻辑
|
||||
- [ ] 会话过期处理
|
||||
|
||||
### API 测试脚本
|
||||
|
||||
```bash
|
||||
# 查看当前 API 响应
|
||||
curl http://127.0.0.1:28001/api/statistics
|
||||
|
||||
# 登录后测试受保护的 API
|
||||
curl -b cookies.txt http://127.0.0.1:28001/api/messages
|
||||
```
|
||||
|
||||
## 与 Python 版本的差异
|
||||
|
||||
1. **独立数据库**: 完全独立的 `sms_receiver_go.db`
|
||||
2.二进制部署: 单文件运行,无需 Python 环境
|
||||
3. **并发处理**: Go 原生支持高并发
|
||||
4. **编译部署**: 编译后的二进制更小,启动更快
|
||||
|
||||
## 后续计划
|
||||
|
||||
### 短期 (2026-02-08)
|
||||
|
||||
1. 修复前端显示问题
|
||||
- 检查模板变量传递
|
||||
- 验证模板函数注册
|
||||
- 测试数据渲染
|
||||
|
||||
2. 完善功能对齐
|
||||
- 验证时区转换一致性
|
||||
- 测试所有 API 接口
|
||||
- 确保统计逻辑一致
|
||||
|
||||
3. 性能测试
|
||||
- 对比 Python 版本响应时间
|
||||
- 测试并发处理能力
|
||||
|
||||
### 中期
|
||||
|
||||
1. 部署优化
|
||||
- Systemd 服务配置
|
||||
- 日志轮转配置
|
||||
- 监控告警集成
|
||||
|
||||
2. 功能增强
|
||||
- WebSocket 实时推送
|
||||
- 批量导出功能
|
||||
- 更多统计维度
|
||||
|
||||
## 联系方式
|
||||
|
||||
- 问题反馈: 通过 Telegram 反馈
|
||||
- 项目文档: `DEVELOPMENT.md` (Python 版本)
|
||||
- 此文档: `GO_REFACTOR_PROGRESS.md` (Go 版本进度)
|
||||
129
auth/auth.go
Normal file
129
auth/auth.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"sms-receiver-go/config"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
var store *sessions.CookieStore
|
||||
|
||||
// SessionKey 会话相关的 key
|
||||
const (
|
||||
SessionKeyLoggedIn = "logged_in"
|
||||
SessionKeyUsername = "username"
|
||||
SessionKeyLoginTime = "login_time"
|
||||
SessionKeyLastActive = "last_activity"
|
||||
)
|
||||
|
||||
// Init 初始化会话存储
|
||||
func Init(secretKey string) {
|
||||
// 支持 hex 和 base64 格式的密钥
|
||||
key := []byte(secretKey)
|
||||
if len(key) == 64 { // hex 格式 32 字节
|
||||
var err error
|
||||
key, err = hex.DecodeString(secretKey)
|
||||
if err != nil {
|
||||
log.Printf("警告: hex 解码失败,使用原始密钥: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
store = sessions.NewCookieStore(key)
|
||||
store.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 86400 * 7, // 7天
|
||||
HttpOnly: true,
|
||||
// 不设置 SameSite,让浏览器使用默认值(Lax),在同站上下文中工作正常
|
||||
// SameSite: http.SameSiteNoneMode,
|
||||
// Secure: true,
|
||||
}
|
||||
log.Printf("会话存储初始化完成,密钥长度: %d 字节", len(key))
|
||||
}
|
||||
|
||||
// GetStore 获取会话存储
|
||||
func GetStore() *sessions.CookieStore {
|
||||
return store
|
||||
}
|
||||
|
||||
// Login 登录
|
||||
func Login(w http.ResponseWriter, r *http.Request, username string) error {
|
||||
session, err := store.Get(r, "sms-receiver")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session.Values[SessionKeyLoggedIn] = true
|
||||
session.Values[SessionKeyUsername] = username
|
||||
session.Values[SessionKeyLoginTime] = time.Now().Unix()
|
||||
session.Values[SessionKeyLastActive] = time.Now().Unix()
|
||||
|
||||
return session.Save(r, w)
|
||||
}
|
||||
|
||||
// Logout 登出
|
||||
func Logout(r *http.Request, w http.ResponseWriter) error {
|
||||
session, err := store.Get(r, "sms-receiver")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session.Values = make(map[interface{}]interface{})
|
||||
session.Save(r, w)
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsLoggedIn 检查是否已登录
|
||||
func IsLoggedIn(r *http.Request) (bool, string) {
|
||||
session, err := store.Get(r, "sms-receiver")
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
loggedIn, ok := session.Values[SessionKeyLoggedIn].(bool)
|
||||
if !ok || !loggedIn {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
username, _ := session.Values[SessionKeyUsername].(string)
|
||||
|
||||
// 检查会话是否过期
|
||||
cfg := config.Get()
|
||||
if cfg != nil {
|
||||
lastActive, ok := session.Values[SessionKeyLastActive].(int64)
|
||||
if ok {
|
||||
sessionLifetime := cfg.GetSessionLifetimeDuration()
|
||||
if time.Now().Unix()-lastActive > int64(sessionLifetime.Seconds()) {
|
||||
return false, ""
|
||||
}
|
||||
// 更新最后活跃时间
|
||||
session.Values[SessionKeyLastActive] = time.Now().Unix()
|
||||
}
|
||||
}
|
||||
|
||||
return true, username
|
||||
}
|
||||
|
||||
// CheckLogin 检查登录状态,未登录则跳转到登录页
|
||||
func CheckLogin(w http.ResponseWriter, r *http.Request) (bool, string) {
|
||||
loggedIn, username := IsLoggedIn(r)
|
||||
if !loggedIn {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return false, ""
|
||||
}
|
||||
return true, username
|
||||
}
|
||||
|
||||
// GetCurrentUser 获取当前用户名
|
||||
func GetCurrentUser(r *http.Request) string {
|
||||
session, err := store.Get(r, "sms-receiver")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
username, _ := session.Values[SessionKeyUsername].(string)
|
||||
return username
|
||||
}
|
||||
34
config.yaml
Normal file
34
config.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
# SMS Receiver Go - 配置文件
|
||||
app:
|
||||
name: "短信转发接收端"
|
||||
version: "1.0.0"
|
||||
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 28001
|
||||
debug: true
|
||||
|
||||
security:
|
||||
enabled: true
|
||||
username: "admin"
|
||||
password: "admin123"
|
||||
session_lifetime: 3600
|
||||
secret_key: "1e81b5f9e5a695eba01e996b14871db8899b08e111cf8252df8aa4c91d1c7144"
|
||||
sign_verify: true
|
||||
sign_max_age: 3600000
|
||||
|
||||
sms:
|
||||
max_messages: 10000
|
||||
auto_cleanup: true
|
||||
cleanup_days: 30
|
||||
|
||||
database:
|
||||
path: "sms_receiver_go.db"
|
||||
|
||||
timezone: "Asia/Shanghai"
|
||||
|
||||
api_tokens:
|
||||
- name: "默认配置"
|
||||
token: "default_token"
|
||||
secret: ""
|
||||
enabled: true
|
||||
142
config/config.go
Normal file
142
config/config.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
App AppConfig `mapstructure:"app"`
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Security SecurityConfig `mapstructure:"security"`
|
||||
SMS SMSConfig `mapstructure:"sms"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
Timezone string `mapstructure:"timezone"`
|
||||
APITokens []APIToken `mapstructure:"api_tokens"`
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
Name string `mapstructure:"name"`
|
||||
Version string `mapstructure:"version"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Debug bool `mapstructure:"debug"`
|
||||
}
|
||||
|
||||
type SecurityConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
SessionLifetime int `mapstructure:"session_lifetime"`
|
||||
SecretKey string `mapstructure:"secret_key"`
|
||||
SignVerify bool `mapstructure:"sign_verify"`
|
||||
SignMaxAge int64 `mapstructure:"sign_max_age"`
|
||||
}
|
||||
|
||||
type SMSConfig struct {
|
||||
MaxMessages int `mapstructure:"max_messages"`
|
||||
AutoCleanup bool `mapstructure:"auto_cleanup"`
|
||||
CleanupDays int `mapstructure:"cleanup_days"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Path string `mapstructure:"path"`
|
||||
}
|
||||
|
||||
type APIToken struct {
|
||||
Name string `mapstructure:"name"`
|
||||
Token string `mapstructure:"token"`
|
||||
Secret string `mapstructure:"secret"`
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
}
|
||||
|
||||
var cfg *Config
|
||||
|
||||
func Load(configPath string) (*Config, error) {
|
||||
viper.SetConfigFile(configPath)
|
||||
viper.SetConfigType("yaml")
|
||||
|
||||
// 允许环境变量覆盖
|
||||
viper.AutomaticEnv()
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("读取配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
cfg = &Config{}
|
||||
if err := viper.Unmarshal(cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func Get() *Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// GetSessionLifetimeDuration 返回会话 lifetime 为 duration
|
||||
func (c *Config) GetSessionLifetimeDuration() time.Duration {
|
||||
return time.Duration(c.Security.SessionLifetime) * time.Second
|
||||
}
|
||||
|
||||
// GetSignMaxAgeDuration 返回签名最大有效期
|
||||
func (c *Config) GetSignMaxAgeDuration() time.Duration {
|
||||
return time.Duration(c.Security.SignMaxAge) * time.Millisecond
|
||||
}
|
||||
|
||||
// GetServerAddress 返回服务器地址
|
||||
func (c *Config) GetServerAddress() string {
|
||||
return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port)
|
||||
}
|
||||
|
||||
// GetTokenByName 根据名称获取 Token 配置
|
||||
func (c *Config) GetTokenByName(name string) *APIToken {
|
||||
for i := range c.APITokens {
|
||||
if c.APITokens[i].Name == name && c.APITokens[i].Enabled {
|
||||
return &c.APITokens[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTokenByValue 根据 token 值获取配置
|
||||
func (c *Config) GetTokenByValue(token string) *APIToken {
|
||||
for i := range c.APITokens {
|
||||
if c.APITokens[i].Token == token && c.APITokens[i].Enabled {
|
||||
return &c.APITokens[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save 保存配置到文件
|
||||
func (c *Config) Save(path string) error {
|
||||
viper.Set("app", c.App)
|
||||
viper.Set("server", c.Server)
|
||||
viper.Set("security", c.Security)
|
||||
viper.Set("sms", c.SMS)
|
||||
viper.Set("database", c.Database)
|
||||
viper.Set("timezone", c.Timezone)
|
||||
viper.Set("api_tokens", c.APITokens)
|
||||
|
||||
return viper.WriteConfigAs(path)
|
||||
}
|
||||
|
||||
// LoadDefault 加载默认配置文件
|
||||
func LoadDefault() (*Config, error) {
|
||||
configPath := "config.yaml"
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
// 尝试查找上层目录
|
||||
if _, err := os.Stat("../config.yaml"); err == nil {
|
||||
configPath = "../config.yaml"
|
||||
}
|
||||
}
|
||||
return Load(configPath)
|
||||
}
|
||||
325
database/database.go
Normal file
325
database/database.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sms-receiver-go/config"
|
||||
"sms-receiver-go/models"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
var db *sql.DB
|
||||
|
||||
// Init 初始化数据库
|
||||
func Init(cfg *config.DatabaseConfig) error {
|
||||
var err error
|
||||
db, err = sql.Open("sqlite3", cfg.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开数据库失败: %w", err)
|
||||
}
|
||||
|
||||
if err = db.Ping(); err != nil {
|
||||
return fmt.Errorf("数据库连接失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建表
|
||||
if err = createTables(); err != nil {
|
||||
return fmt.Errorf("创建表失败: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("数据库初始化成功: %s", cfg.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// createTables 创建数据表
|
||||
func createTables() error {
|
||||
// 短信消息表
|
||||
createMessagesSQL := `
|
||||
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,
|
||||
ip_address TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`
|
||||
|
||||
// 接收日志表
|
||||
createLogsSQL := `
|
||||
CREATE TABLE IF NOT EXISTS receive_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_number TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
sign TEXT,
|
||||
sign_valid INTEGER,
|
||||
ip_address TEXT,
|
||||
status TEXT NOT NULL,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`
|
||||
|
||||
// 创建索引
|
||||
createIndexesSQL := `
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_from ON sms_messages(from_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON sms_messages(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_created ON sms_messages(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_created ON receive_logs(created_at);
|
||||
`
|
||||
|
||||
statements := []string{createMessagesSQL, createLogsSQL, createIndexesSQL}
|
||||
for _, stmt := range statements {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
return fmt.Errorf("执行 SQL 失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InsertMessage 插入短信消息
|
||||
func InsertMessage(msg *models.SMSMessage) (int64, error) {
|
||||
result, err := db.Exec(`
|
||||
INSERT INTO sms_messages (from_number, content, timestamp, device_info, sim_info, sign_verified, ip_address)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
msg.FromNumber,
|
||||
msg.Content,
|
||||
msg.Timestamp,
|
||||
msg.DeviceInfo,
|
||||
msg.SIMInfo,
|
||||
msg.SignVerified,
|
||||
msg.IPAddress,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("插入消息失败: %w", err)
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
// InsertLog 插入接收日志
|
||||
func InsertLog(log *models.ReceiveLog) (int64, error) {
|
||||
result, err := db.Exec(`
|
||||
INSERT INTO receive_logs (from_number, content, timestamp, sign, sign_valid, ip_address, status, error_message)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
log.FromNumber,
|
||||
log.Content,
|
||||
log.Timestamp,
|
||||
log.Sign,
|
||||
log.SignValid,
|
||||
log.IPAddress,
|
||||
log.Status,
|
||||
log.ErrorMessage,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("插入日志失败: %w", err)
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
// GetMessages 获取短信列表
|
||||
func GetMessages(page, limit int, from string, search string) ([]models.SMSMessage, int64, error) {
|
||||
offset := (page - 1) * limit
|
||||
|
||||
// 构建查询条件
|
||||
var conditions []string
|
||||
var args []interface{}
|
||||
|
||||
if from != "" {
|
||||
conditions = append(conditions, "from_number = ?")
|
||||
args = append(args, from)
|
||||
}
|
||||
if search != "" {
|
||||
conditions = append(conditions, "(from_number LIKE ? OR content LIKE ?)")
|
||||
args = append(args, "%"+search+"%", "%"+search+"%")
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
if len(conditions) > 0 {
|
||||
whereClause = "WHERE " + strings.Join(conditions, " AND ")
|
||||
}
|
||||
|
||||
// 查询总数
|
||||
var total int64
|
||||
countSQL := fmt.Sprintf("SELECT COUNT(*) FROM sms_messages %s", whereClause)
|
||||
if err := db.QueryRow(countSQL, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("查询总数失败: %w", err)
|
||||
}
|
||||
|
||||
// 查询数据(按短信时间戳排序,与 Python 版本一致)
|
||||
querySQL := fmt.Sprintf(`
|
||||
SELECT id, from_number, content, timestamp, device_info, sim_info, sign_verified, ip_address, created_at
|
||||
FROM sms_messages
|
||||
%s
|
||||
ORDER BY timestamp DESC, id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, whereClause)
|
||||
|
||||
args = append(args, limit, offset)
|
||||
rows, err := db.Query(querySQL, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("查询消息失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var messages []models.SMSMessage
|
||||
for rows.Next() {
|
||||
var msg models.SMSMessage
|
||||
err := rows.Scan(
|
||||
&msg.ID,
|
||||
&msg.FromNumber,
|
||||
&msg.Content,
|
||||
&msg.Timestamp,
|
||||
&msg.DeviceInfo,
|
||||
&msg.SIMInfo,
|
||||
&msg.SignVerified,
|
||||
&msg.IPAddress,
|
||||
&msg.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("扫描消息失败: %w", err)
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
return messages, total, nil
|
||||
}
|
||||
|
||||
// GetMessageByID 根据 ID 获取消息详情
|
||||
func GetMessageByID(id int64) (*models.SMSMessage, error) {
|
||||
var msg models.SMSMessage
|
||||
err := db.QueryRow(`
|
||||
SELECT id, from_number, content, timestamp, device_info, sim_info, sign_verified, ip_address, created_at
|
||||
FROM sms_messages WHERE id = ?
|
||||
`, id).Scan(
|
||||
&msg.ID,
|
||||
&msg.FromNumber,
|
||||
&msg.Content,
|
||||
&msg.Timestamp,
|
||||
&msg.DeviceInfo,
|
||||
&msg.SIMInfo,
|
||||
&msg.SignVerified,
|
||||
&msg.IPAddress,
|
||||
&msg.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("查询消息失败: %w", err)
|
||||
}
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
// GetStatistics 获取统计信息
|
||||
func GetStatistics() (*models.Statistics, error) {
|
||||
stats := &models.Statistics{}
|
||||
|
||||
// 总数
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM sms_messages").Scan(&stats.Total); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 今日数量
|
||||
today := time.Now().Format("2006-01-02")
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM sms_messages WHERE date(created_at) = ?", today).Scan(&stats.Today); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 本周数量
|
||||
weekStart := time.Now().AddDate(0, 0, -int(time.Now().Weekday())+1).Format("2006-01-02")
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM sms_messages WHERE created_at >= ?", weekStart).Scan(&stats.Week); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 签名验证通过数量
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM sms_messages WHERE sign_verified = 1").Scan(&stats.Verified); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 签名验证未通过数量
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM sms_messages WHERE sign_verified = 0").Scan(&stats.Unverified); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetLogs 获取接收日志
|
||||
func GetLogs(page, limit int) ([]models.ReceiveLog, int64, error) {
|
||||
offset := (page - 1) * limit
|
||||
|
||||
// 查询总数
|
||||
var total int64
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM receive_logs").Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := db.Query(`
|
||||
SELECT id, from_number, content, timestamp, sign, sign_valid, ip_address, status, error_message, created_at
|
||||
FROM receive_logs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []models.ReceiveLog
|
||||
for rows.Next() {
|
||||
var log models.ReceiveLog
|
||||
err := rows.Scan(
|
||||
&log.ID,
|
||||
&log.FromNumber,
|
||||
&log.Content,
|
||||
&log.Timestamp,
|
||||
&log.Sign,
|
||||
&log.SignValid,
|
||||
&log.IPAddress,
|
||||
&log.Status,
|
||||
&log.ErrorMessage,
|
||||
&log.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
logs = append(logs, log)
|
||||
}
|
||||
|
||||
return logs, total, nil
|
||||
}
|
||||
|
||||
// CleanupOldMessages 清理旧消息
|
||||
func CleanupOldMessages(days int) (int64, error) {
|
||||
cutoff := time.Now().AddDate(0, 0, -days).Format("2006-01-02 15:04:05")
|
||||
result, err := db.Exec("DELETE FROM sms_messages WHERE created_at < ?", cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
// GetDB 获取数据库实例
|
||||
func GetDB() *sql.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
// Close 关闭数据库连接
|
||||
func Close() error {
|
||||
if db != nil {
|
||||
return db.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
26
go.mod
Normal file
26
go.mod
Normal file
@@ -0,0 +1,26 @@
|
||||
module sms-receiver-go
|
||||
|
||||
go 1.24.4
|
||||
|
||||
require (
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/mattn/go-sqlite3 v1.14.33
|
||||
github.com/spf13/viper v1.21.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
)
|
||||
57
go.sum
Normal file
57
go.sum
Normal file
@@ -0,0 +1,57 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
523
handlers/handlers.go
Normal file
523
handlers/handlers.go
Normal file
@@ -0,0 +1,523 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sms-receiver-go/auth"
|
||||
"sms-receiver-go/config"
|
||||
"sms-receiver-go/database"
|
||||
"sms-receiver-go/models"
|
||||
"sms-receiver-go/sign"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
var templates *template.Template
|
||||
|
||||
// InitTemplates 初始化模板
|
||||
func InitTemplates(templatesPath string) error {
|
||||
// 先创建带函数的模板
|
||||
funcMap := template.FuncMap{
|
||||
// 基本运算(支持 int 和 int64)
|
||||
"add": func(a, b interface{}) int64 {
|
||||
ai, _ := a.(int)
|
||||
ai64, _ := a.(int64)
|
||||
bi, _ := b.(int)
|
||||
bi64, _ := b.(int64)
|
||||
if ai64 == 0 && ai != 0 {
|
||||
ai64 = int64(ai)
|
||||
}
|
||||
if bi64 == 0 && bi != 0 {
|
||||
bi64 = int64(bi)
|
||||
}
|
||||
return ai64 + bi64
|
||||
},
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"mul": func(a, b int) int { return a * b },
|
||||
"div": func(a, b int) int { return a / b },
|
||||
"ceilDiv": func(a, b int) int { return (a + b - 1) / b },
|
||||
// 比较函数
|
||||
"eq": func(a, b interface{}) bool { return a == b },
|
||||
"ne": func(a, b interface{}) bool { return a != b },
|
||||
"lt": func(a, b int) bool { return a < b },
|
||||
"le": func(a, b int) bool { return a <= b },
|
||||
"gt": func(a, b int) bool { return a > b },
|
||||
"ge": func(a, b int) bool { return a >= b },
|
||||
// 其他
|
||||
"seq": createRange,
|
||||
"mulFloat": func(a, b int64) float64 { return float64(a) * float64(b) / 100 },
|
||||
}
|
||||
|
||||
var err error
|
||||
templates, err = template.New("root").Funcs(funcMap).ParseGlob(templatesPath + "/*.html")
|
||||
if err != nil {
|
||||
return fmt.Errorf("加载模板失败: %w", err)
|
||||
}
|
||||
|
||||
// 调试: 打印加载的模板名称
|
||||
log.Printf("已加载的模板:")
|
||||
for _, t := range templates.Templates() {
|
||||
log.Printf(" - %s", t.Name())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createRange 创建整数序列
|
||||
func createRange(start, end int) []int {
|
||||
result := make([]int, end-start+1)
|
||||
for i := start; i <= end; i++ {
|
||||
result[i-start] = i
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Index 首页 - 短信列表
|
||||
func Index(w http.ResponseWriter, r *http.Request) {
|
||||
loggedIn, _ := auth.CheckLogin(w, r)
|
||||
if !loggedIn {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取查询参数
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit := 20
|
||||
from := r.URL.Query().Get("from")
|
||||
search := r.URL.Query().Get("search")
|
||||
|
||||
messages, total, err := database.GetMessages(page, limit, from, search)
|
||||
if err != nil {
|
||||
log.Printf("获取消息失败: %v", err)
|
||||
http.Error(w, "获取消息失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Printf("查询结果: 总数=%d, 本页=%d 条", total, len(messages))
|
||||
|
||||
// 获取统计数据
|
||||
stats, err := database.GetStatistics()
|
||||
if err != nil {
|
||||
log.Printf("获取统计失败: %v", err)
|
||||
}
|
||||
log.Printf("统计: 总数=%d, 今日=%d, 本周=%d", stats.Total, stats.Today, stats.Week)
|
||||
// 获取所有发送方号码(用于筛选)
|
||||
fromNumbers, _ := getFromNumbers()
|
||||
|
||||
// 计算总页数
|
||||
totalPages := (total + int64(limit) - 1) / int64(limit)
|
||||
if totalPages == 0 {
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
// 格式化时间(转换为本地时区显示)
|
||||
cfg := config.Get()
|
||||
loc, _ := time.LoadLocation(cfg.Timezone)
|
||||
for i := range messages {
|
||||
// 优先显示短信时间戳(本地时间)
|
||||
localTime := time.UnixMilli(messages[i].Timestamp).In(loc)
|
||||
messages[i].LocalTimestampStr = localTime.Format("2006-01-02 15:04:05")
|
||||
// 同时保留 created_at 作为排序参考
|
||||
messages[i].CreatedAt = messages[i].CreatedAt.In(loc)
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"messages": messages,
|
||||
"stats": stats,
|
||||
"total": total,
|
||||
"totalPages": int(totalPages),
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"fromNumbers": fromNumbers,
|
||||
"selectedFrom": from,
|
||||
"search": search,
|
||||
}
|
||||
|
||||
log.Printf("传递给模板的数据: messages=%d, total=%d, totalPages=%d",
|
||||
len(messages), total, totalPages)
|
||||
if len(messages) > 0 {
|
||||
log.Printf("第一条消息: ID=%d, From=%s, Content=%s",
|
||||
messages[0].ID, messages[0].FromNumber, messages[0].Content)
|
||||
}
|
||||
|
||||
if err := templates.ExecuteTemplate(w, "index.html", data); err != nil {
|
||||
log.Printf("模板执行错误: %v", err)
|
||||
http.Error(w, "模板渲染失败", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// Login 登录页面
|
||||
func Login(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
// 显示登录页面
|
||||
errorMsg := r.URL.Query().Get("error")
|
||||
templates.ExecuteTemplate(w, "login.html", map[string]string{
|
||||
"error": errorMsg,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 处理登录请求
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
|
||||
cfg := config.Get()
|
||||
if cfg.Security.Enabled {
|
||||
if username == cfg.Security.Username && password == cfg.Security.Password {
|
||||
if err := auth.Login(w, r, username); err != nil {
|
||||
log.Printf("创建会话失败: %v", err)
|
||||
http.Error(w, "创建会话失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// 登录失败
|
||||
templates.ExecuteTemplate(w, "login.html", map[string]string{
|
||||
"error": "用户名或密码错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 未启用登录验证
|
||||
auth.Login(w, r, username)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// Logout 登出
|
||||
func Logout(w http.ResponseWriter, r *http.Request) {
|
||||
auth.Logout(r, w)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// MessageDetail 短信详情页面
|
||||
func MessageDetail(w http.ResponseWriter, r *http.Request) {
|
||||
loggedIn, _ := auth.CheckLogin(w, r)
|
||||
if !loggedIn {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.ParseInt(vars["id"], 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "无效的消息 ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := database.GetMessageByID(id)
|
||||
if err != nil {
|
||||
http.Error(w, "获取消息失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if msg == nil {
|
||||
http.Error(w, "消息不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
cfg := config.Get()
|
||||
loc, _ := time.LoadLocation(cfg.Timezone)
|
||||
localTime := time.UnixMilli(msg.Timestamp).In(loc)
|
||||
msg.TimestampStr = localTime.Format("2006-01-02 15:04:05")
|
||||
msg.Content = strings.ReplaceAll(msg.Content, "\n", "<br>")
|
||||
|
||||
templates.ExecuteTemplate(w, "message_detail.html", msg)
|
||||
}
|
||||
|
||||
// Logs 接收日志页面
|
||||
func Logs(w http.ResponseWriter, r *http.Request) {
|
||||
loggedIn, _ := auth.CheckLogin(w, r)
|
||||
if !loggedIn {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit := 50
|
||||
|
||||
logs, total, err := database.GetLogs(page, limit)
|
||||
if err != nil {
|
||||
http.Error(w, "获取日志失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 计算总页数
|
||||
totalPages := (total + int64(limit) - 1) / int64(limit)
|
||||
if totalPages == 0 {
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"logs": logs,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"totalPages": int(totalPages),
|
||||
}
|
||||
|
||||
templates.ExecuteTemplate(w, "logs.html", data)
|
||||
}
|
||||
|
||||
// Statistics 统计信息页面
|
||||
func Statistics(w http.ResponseWriter, r *http.Request) {
|
||||
loggedIn, _ := auth.CheckLogin(w, r)
|
||||
if !loggedIn {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := database.GetStatistics()
|
||||
if err != nil {
|
||||
http.Error(w, "获取统计失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"stats": stats,
|
||||
}
|
||||
|
||||
templates.ExecuteTemplate(w, "statistics.html", data)
|
||||
}
|
||||
|
||||
// ReceiveSMS API - 接收短信
|
||||
func ReceiveSMS(w http.ResponseWriter, r *http.Request) {
|
||||
// 解析 multipart/form-data (优先)
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
// 回退到 ParseForm
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeJSON(w, models.APIResponse{
|
||||
Success: false,
|
||||
Error: "解析请求失败: " + err.Error(),
|
||||
}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 获取参数
|
||||
from := r.FormValue("from")
|
||||
content := r.FormValue("content")
|
||||
|
||||
if from == "" || content == "" {
|
||||
writeJSON(w, models.APIResponse{
|
||||
Success: false,
|
||||
Error: "缺少必填参数 (from: '" + from + "', content: '" + content + "')",
|
||||
}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取可选参数
|
||||
timestampStr := r.FormValue("timestamp")
|
||||
timestamp := time.Now().UnixMilli()
|
||||
if timestampStr != "" {
|
||||
if t, err := strconv.ParseInt(timestampStr, 10, 64); err == nil {
|
||||
timestamp = t
|
||||
}
|
||||
}
|
||||
|
||||
signStr := r.FormValue("sign")
|
||||
device := r.FormValue("device")
|
||||
sim := r.FormValue("sim")
|
||||
|
||||
// 获取 Token(从 query string 或 form)
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
token = r.FormValue("token")
|
||||
}
|
||||
|
||||
// 验证签名
|
||||
cfg := config.Get()
|
||||
signValid := sql.NullBool{Bool: true, Valid: true}
|
||||
if token != "" && cfg.Security.SignVerify {
|
||||
valid, err := sign.VerifySign(token, timestamp, signStr, &cfg.Security)
|
||||
if err != nil {
|
||||
writeJSON(w, models.APIResponse{
|
||||
Success: false,
|
||||
Error: "签名验证错误",
|
||||
}, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
signValid.Bool = valid
|
||||
signValid.Valid = true
|
||||
if !valid {
|
||||
signValid.Bool = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存消息
|
||||
msg := &models.SMSMessage{
|
||||
FromNumber: from,
|
||||
Content: content,
|
||||
Timestamp: timestamp,
|
||||
DeviceInfo: sql.NullString{String: device, Valid: device != ""},
|
||||
SIMInfo: sql.NullString{String: sim, Valid: sim != ""},
|
||||
SignVerified: signValid,
|
||||
IPAddress: getClientIP(r),
|
||||
}
|
||||
|
||||
messageID, err := database.InsertMessage(msg)
|
||||
if err != nil {
|
||||
// 记录失败日志
|
||||
log := &models.ReceiveLog{
|
||||
FromNumber: from,
|
||||
Content: content,
|
||||
Timestamp: timestamp,
|
||||
Sign: sql.NullString{String: signStr, Valid: signStr != ""},
|
||||
SignValid: signValid,
|
||||
IPAddress: getClientIP(r),
|
||||
Status: "error",
|
||||
ErrorMessage: sql.NullString{String: err.Error(), Valid: true},
|
||||
}
|
||||
database.InsertLog(log)
|
||||
|
||||
writeJSON(w, models.APIResponse{
|
||||
Success: false,
|
||||
Error: "保存消息失败",
|
||||
}, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 记录成功日志
|
||||
log := &models.ReceiveLog{
|
||||
FromNumber: from,
|
||||
Content: content,
|
||||
Timestamp: timestamp,
|
||||
Sign: sql.NullString{String: signStr, Valid: signStr != ""},
|
||||
SignValid: signValid,
|
||||
IPAddress: getClientIP(r),
|
||||
Status: "success",
|
||||
}
|
||||
database.InsertLog(log)
|
||||
|
||||
writeJSON(w, models.APIResponse{
|
||||
Success: true,
|
||||
Message: "短信已接收",
|
||||
MessageID: messageID,
|
||||
}, http.StatusOK)
|
||||
}
|
||||
|
||||
// APIGetMessages API - 获取消息列表
|
||||
func APIGetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
if !isAPIAuthenticated(r) {
|
||||
writeJSON(w, models.APIResponse{Success: false, Error: "未授权"}, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
from := r.URL.Query().Get("from")
|
||||
search := r.URL.Query().Get("search")
|
||||
|
||||
messages, total, err := database.GetMessages(page, limit, from, search)
|
||||
if err != nil {
|
||||
writeJSON(w, models.APIResponse{Success: false, Error: "获取消息失败"}, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
cfg := config.Get()
|
||||
loc, _ := time.LoadLocation(cfg.Timezone)
|
||||
for i := range messages {
|
||||
localTime := time.UnixMilli(messages[i].Timestamp).In(loc)
|
||||
messages[i].LocalTimestampStr = localTime.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
response := models.MessageListResponse{
|
||||
Success: true,
|
||||
Data: messages,
|
||||
Total: total,
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
}
|
||||
writeJSON(w, response, http.StatusOK)
|
||||
}
|
||||
|
||||
// APIStatistics API - 获取统计信息
|
||||
func APIStatistics(w http.ResponseWriter, r *http.Request) {
|
||||
if !isAPIAuthenticated(r) {
|
||||
writeJSON(w, models.APIResponse{Success: false, Error: "未授权"}, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := database.GetStatistics()
|
||||
if err != nil {
|
||||
writeJSON(w, models.APIResponse{Success: false, Error: "获取统计失败"}, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := models.StatisticsResponse{
|
||||
Success: true,
|
||||
Data: *stats,
|
||||
}
|
||||
writeJSON(w, response, http.StatusOK)
|
||||
}
|
||||
|
||||
// StaticFile 处理静态文件
|
||||
func StaticFile(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "static"+r.URL.Path)
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
|
||||
func writeJSON(w http.ResponseWriter, data interface{}, status int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func getClientIP(r *http.Request) string {
|
||||
forwarded := r.Header.Get("X-Forwarded-For")
|
||||
if forwarded != "" {
|
||||
return strings.Split(forwarded, ",")[0]
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
func isAPIAuthenticated(r *http.Request) bool {
|
||||
cfg := config.Get()
|
||||
if !cfg.Security.Enabled {
|
||||
return true
|
||||
}
|
||||
loggedIn, _ := auth.IsLoggedIn(r)
|
||||
return loggedIn
|
||||
}
|
||||
|
||||
func getFromNumbers() ([]string, error) {
|
||||
rows, err := database.GetDB().Query("SELECT DISTINCT from_number FROM sms_messages ORDER BY from_number")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var numbers []string
|
||||
for rows.Next() {
|
||||
var number string
|
||||
if err := rows.Scan(&number); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
numbers = append(numbers, number)
|
||||
}
|
||||
return numbers, nil
|
||||
}
|
||||
117
main.go
Normal file
117
main.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"sms-receiver-go/auth"
|
||||
"sms-receiver-go/config"
|
||||
"sms-receiver-go/database"
|
||||
"sms-receiver-go/handlers"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 命令行参数
|
||||
configPath := flag.String("config", "config.yaml", "配置文件路径")
|
||||
templatesPath := flag.String("templates", "templates", "模板目录路径")
|
||||
flag.Parse()
|
||||
|
||||
// 加载配置
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
log.Printf("配置加载成功: %s v%s", cfg.App.Name, cfg.App.Version)
|
||||
|
||||
// 初始化数据库
|
||||
if err := database.Init(&cfg.Database); err != nil {
|
||||
log.Fatalf("初始化数据库失败: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
// 初始化会话存储
|
||||
auth.Init(cfg.Security.SecretKey)
|
||||
|
||||
// 初始化模板
|
||||
if err := handlers.InitTemplates(*templatesPath); err != nil {
|
||||
log.Fatalf("初始化模板失败: %v", err)
|
||||
}
|
||||
|
||||
// 创建路由器
|
||||
r := mux.NewRouter()
|
||||
|
||||
// 静态文件
|
||||
r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||
|
||||
// 页面路由
|
||||
r.HandleFunc("/", handlers.Index)
|
||||
r.HandleFunc("/login", handlers.Login)
|
||||
r.HandleFunc("/logout", handlers.Logout)
|
||||
r.HandleFunc("/message/{id}", handlers.MessageDetail)
|
||||
r.HandleFunc("/logs", handlers.Logs)
|
||||
r.HandleFunc("/statistics", handlers.Statistics)
|
||||
|
||||
// API 路由
|
||||
r.HandleFunc("/api/receive", handlers.ReceiveSMS)
|
||||
r.HandleFunc("/api/messages", handlers.APIGetMessages)
|
||||
r.HandleFunc("/api/statistics", handlers.APIStatistics)
|
||||
|
||||
// 健康检查
|
||||
r.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
// 配置服务器
|
||||
server := &http.Server{
|
||||
Addr: cfg.GetServerAddress(),
|
||||
Handler: r,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// 启动后台清理任务
|
||||
go startCleanupTask(cfg)
|
||||
|
||||
// 优雅关闭
|
||||
go func() {
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigChan
|
||||
|
||||
log.Println("正在关闭服务...")
|
||||
server.Close()
|
||||
}()
|
||||
|
||||
log.Printf("服务启动: http://%s", cfg.GetServerAddress())
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("服务器启动失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// startCleanupTask 启动定期清理任务
|
||||
func startCleanupTask(cfg *config.Config) {
|
||||
if !cfg.SMS.AutoCleanup {
|
||||
return
|
||||
}
|
||||
|
||||
// 每天凌晨 3 点执行清理
|
||||
for {
|
||||
now := time.Now()
|
||||
next := time.Date(now.Year(), now.Month(), now.Day()+1, 3, 0, 0, 0, now.Location())
|
||||
time.Sleep(next.Sub(now))
|
||||
|
||||
if _, err := database.CleanupOldMessages(cfg.SMS.CleanupDays); err != nil {
|
||||
log.Printf("清理旧消息失败: %v", err)
|
||||
} else {
|
||||
log.Println("自动清理旧消息完成")
|
||||
}
|
||||
}
|
||||
}
|
||||
67
models/message.go
Normal file
67
models/message.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SMSMessage 短信消息模型
|
||||
type SMSMessage struct {
|
||||
ID int64 `json:"id"`
|
||||
FromNumber string `json:"from_number"`
|
||||
Content string `json:"content"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
TimestampStr string `json:"timestamp_str,omitempty"` // 显示用
|
||||
LocalTimestampStr string `json:"local_timestamp_str,omitempty"` // 显示用
|
||||
DeviceInfo sql.NullString `json:"device_info,omitempty"`
|
||||
SIMInfo sql.NullString `json:"sim_info,omitempty"`
|
||||
SignVerified sql.NullBool `json:"sign_verified,omitempty"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ReceiveLog 接收日志模型
|
||||
type ReceiveLog struct {
|
||||
ID int64 `json:"id"`
|
||||
FromNumber string `json:"from_number"`
|
||||
Content string `json:"content"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Sign sql.NullString `json:"sign,omitempty"`
|
||||
SignValid sql.NullBool `json:"sign_valid,omitempty"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
Status string `json:"status"`
|
||||
ErrorMessage sql.NullString `json:"error_message,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Statistics 统计信息
|
||||
type Statistics struct {
|
||||
Total int64 `json:"total"`
|
||||
Today int64 `json:"today"`
|
||||
Week int64 `json:"week"`
|
||||
Verified int64 `json:"verified"`
|
||||
Unverified int64 `json:"unverified"`
|
||||
}
|
||||
|
||||
// MessageListResponse 消息列表响应
|
||||
type MessageListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data []SMSMessage `json:"data"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// StatisticsResponse 统计响应
|
||||
type StatisticsResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data Statistics `json:"data"`
|
||||
}
|
||||
|
||||
// APIResponse API 通用响应
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
MessageID int64 `json:"message_id,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
67
sign/sign.go
Normal file
67
sign/sign.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package sign
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"sms-receiver-go/config"
|
||||
)
|
||||
|
||||
// GenerateSign 生成签名
|
||||
func GenerateSign(timestamp int64, secret string) (string, error) {
|
||||
if secret == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
stringToSign := strconv.FormatInt(timestamp, 10) + "\n" + secret
|
||||
|
||||
hmacCode := hmac.New(sha256.New, []byte(secret))
|
||||
hmacCode.Write([]byte(stringToSign))
|
||||
signBytes := hmacCode.Sum(nil)
|
||||
|
||||
// Base64 编码
|
||||
signBase64 := base64.StdEncoding.EncodeToString(signBytes)
|
||||
|
||||
// URL 编码
|
||||
sign := url.QueryEscape(signBase64)
|
||||
|
||||
return sign, nil
|
||||
}
|
||||
|
||||
// VerifySign 验证签名
|
||||
func VerifySign(token string, timestamp int64, sign string, cfg *config.SecurityConfig) (bool, error) {
|
||||
if !cfg.SignVerify || token == "" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// 查找对应的 secret
|
||||
tokenConfig := config.Get().GetTokenByValue(token)
|
||||
if tokenConfig == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
secret := tokenConfig.Secret
|
||||
if secret == "" {
|
||||
// 无 secret,跳过签名验证
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// 检查时间戳是否过期
|
||||
currentTime := time.Now().UnixMilli()
|
||||
if currentTime-timestamp > cfg.SignMaxAge {
|
||||
return false, nil // 时间戳过期
|
||||
}
|
||||
|
||||
// 重新生成签名进行比较
|
||||
expectedSign, err := GenerateSign(timestamp, secret)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// 比较签名
|
||||
return sign == expectedSign, nil
|
||||
}
|
||||
BIN
sms-receiver
Executable file
BIN
sms-receiver
Executable file
Binary file not shown.
BIN
sms-receiver-new
Executable file
BIN
sms-receiver-new
Executable file
Binary file not shown.
BIN
sms_receiver.db
Normal file
BIN
sms_receiver.db
Normal file
Binary file not shown.
BIN
sms_receiver_go.db
Normal file
BIN
sms_receiver_go.db
Normal file
Binary file not shown.
338
static/css/style.css
Normal file
338
static/css/style.css
Normal file
@@ -0,0 +1,338 @@
|
||||
/* 全局样式 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 渐入动画 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.navbar, .content, .stat-card {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.stat-card:nth-child(2) { animation-delay: 0.15s; }
|
||||
.stat-card:nth-child(3) { animation-delay: 0.2s; }
|
||||
.stat-card:nth-child(4) { animation-delay: 0.25s; }
|
||||
|
||||
/* 导航栏 */
|
||||
.navbar {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 15px 25px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
margin-left: 20px;
|
||||
padding: 8px 15px;
|
||||
border-radius: 5px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* 主要内容 */
|
||||
.content {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 搜索框 */
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-box input, .search-box select {
|
||||
padding: 10px 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-box input[type="text"] {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
padding: 10px 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.search-box button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 分页 */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pagination a {
|
||||
padding: 8px 15px;
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.pagination a:hover {
|
||||
background-color: #5a6fd6;
|
||||
}
|
||||
|
||||
.pagination span {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 登录表单 */
|
||||
.login-container {
|
||||
max-width: 400px;
|
||||
margin: 100px auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-form h2 {
|
||||
margin-bottom: 30px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.form-group button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #e74c3c;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* 详情页面 */
|
||||
.detail-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #333;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 25px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-card p {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.stat-card.green {
|
||||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||
}
|
||||
|
||||
.stat-card.orange {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 徽章 */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 50px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* 统计表格 */
|
||||
.stats-table {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.stats-table td {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.stats-table tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
564
templates/index.html
Normal file
564
templates/index.html
Normal file
@@ -0,0 +1,564 @@
|
||||
<!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);
|
||||
animation: slideInDown 0.5s ease-out;
|
||||
}
|
||||
|
||||
.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.active {
|
||||
background: #764ba2;
|
||||
}
|
||||
|
||||
.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);
|
||||
text-align: center;
|
||||
animation: fadeInUp 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.stat-card:nth-child(1) { animation-delay: 0.1s; }
|
||||
.stat-card:nth-child(2) { animation-delay: 0.2s; }
|
||||
.stat-card:nth-child(3) { animation-delay: 0.3s; }
|
||||
.stat-card:nth-child(4) { animation-delay: 0.4s; }
|
||||
|
||||
.stat-card h3 {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.from-numbers-filter {
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 15px;
|
||||
animation: fadeInUp 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
.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: all 0.3s;
|
||||
}
|
||||
|
||||
.from-numbers-filter .tag:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.from-numbers-filter .tag.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
animation: fadeInUp 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.auto-refresh-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.refresh-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 10px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.refresh-toggle.active {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.messages-list {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
animation: fadeInUp 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
animation-delay: 0.7s;
|
||||
}
|
||||
|
||||
.list-view {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.list-view li {
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.list-view li:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.list-view li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.msg-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.from-number {
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.sign-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.sign-badge.yes {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.sign-badge.no {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
animation: fadeInUp 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin-bottom: 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(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;
|
||||
}
|
||||
|
||||
.nav {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📱 短信转发接收端</h1>
|
||||
<div class="nav">
|
||||
<a href="/" class="active">短信列表</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}} / {{add .stats.Verified .stats.Unverified}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if gt (len .fromNumbers) 0}}
|
||||
<div class="from-numbers-filter">
|
||||
<h3>快捷筛选 (按号码)</h3>
|
||||
<div class="tags">
|
||||
<span class="tag {{if eq .selectedFrom ""}}active{{end}}" onclick="filterByNumber('')">全部</span>
|
||||
{{range .fromNumbers}}
|
||||
<span class="tag {{if eq $.selectedFrom .}}active{{end}}" onclick="filterByNumber('{{.}}')">{{.}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="toolbar">
|
||||
<input type="text" id="searchInput" placeholder="搜索内容或号码..." value="{{.search}}">
|
||||
<button onclick="search()">搜索</button>
|
||||
<button onclick="clearSearch()">清除</button>
|
||||
<div class="auto-refresh-info">
|
||||
<span class="refresh-toggle active" id="refreshToggle">
|
||||
<input type="checkbox" id="autoRefresh" checked onchange="toggleAutoRefresh()">
|
||||
<span>自动刷新</span>
|
||||
</span>
|
||||
<span id="refreshCountdown">30s</span>
|
||||
</div>
|
||||
<div class="refresh-btn">
|
||||
<button onclick="location.reload()">立即刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="messages-list">
|
||||
{{if gt (len .messages) 0}}
|
||||
<ul class="list-view">
|
||||
{{range .messages}}
|
||||
<li>
|
||||
<div class="msg-header">
|
||||
<span class="from-number">{{.FromNumber}}</span>
|
||||
<span class="msg-time">{{if .LocalTimestampStr}}{{.LocalTimestampStr}}{{else}}{{.CreatedAt.Format "2006-01-02 15:04:05"}}{{end}}</span>
|
||||
</div>
|
||||
<div class="msg-content">
|
||||
{{.Content}}
|
||||
{{if .SignVerified.Valid}}
|
||||
{{if .SignVerified.Bool}}
|
||||
<span class="sign-badge yes">已验证</span>
|
||||
{{else}}
|
||||
<span class="sign-badge no">未验证</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<h3>暂无短信</h3>
|
||||
<p>等待接收短信...</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if gt .totalPages 1}}
|
||||
<div class="pagination">
|
||||
{{if gt .page 1}}
|
||||
<a href="/?page={{sub .page 1}}&from={{.selectedFrom}}&search={{.search}}">上一页</a>
|
||||
{{end}}
|
||||
|
||||
{{range $p := (seq 1 .totalPages)}}
|
||||
{{if eq $p $.page}}
|
||||
<span class="active">{{$p}}</span>
|
||||
{{else if or (le $p 3) (ge $p (sub $.totalPages 2)) (and (ge $p (sub $.page 1)) (le $p (add $.page 1)))}}
|
||||
<a href="/?page={{$p}}&from={{$.selectedFrom}}&search={{$.search}}">{{$p}}</a>
|
||||
{{else if or (eq $p 4) (eq $p (sub $.totalPages 2))}}
|
||||
<span>...</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if lt .page .totalPages}}
|
||||
<a href="/?page={{add .page 1}}&from={{.selectedFrom}}&search={{.search}}">下一页</a>
|
||||
{{end}}
|
||||
|
||||
<span>共 {{.total}} 条,第 {{.page}} / {{.totalPages}} 页</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let refreshInterval;
|
||||
let countdownInterval;
|
||||
let refreshCountdown = 30;
|
||||
const REFRESH_INTERVAL = 30;
|
||||
|
||||
function search() {
|
||||
const query = document.getElementById('searchInput').value;
|
||||
window.location.href = '/?search=' + encodeURIComponent(query){{if .selectedFrom}}+ '&from={{.selectedFrom}}'{{end}};
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
window.location.href = '/'{{if .selectedFrom}}+ '?from={{.selectedFrom}}'{{end}};
|
||||
}
|
||||
|
||||
function filterByNumber(number) {
|
||||
let url = '/';
|
||||
const params = new URLSearchParams();
|
||||
if (number) params.set('from', number);
|
||||
{{if .search}}params.set('search', '{{.search}}');{{end}}
|
||||
const qs = params.toString();
|
||||
if (qs) url += '?' + qs;
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
const checkbox = document.getElementById('autoRefresh');
|
||||
const toggle = document.getElementById('refreshToggle');
|
||||
|
||||
if (checkbox.checked) {
|
||||
toggle.classList.add('active');
|
||||
startAutoRefresh();
|
||||
} else {
|
||||
toggle.classList.remove('active');
|
||||
stopAutoRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
refreshCountdown = REFRESH_INTERVAL;
|
||||
updateCountdown();
|
||||
|
||||
refreshInterval = setInterval(() => {
|
||||
const params = new URLSearchParams();
|
||||
{{if .search}}params.set('search', '{{.search}}');{{end}}
|
||||
{{if .selectedFrom}}params.set('from', '{{.selectedFrom}}');{{end}}
|
||||
const qs = params.toString();
|
||||
window.location.href = '/?' + qs;
|
||||
}, 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';
|
||||
}
|
||||
|
||||
document.getElementById('searchInput').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
search();
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化自动刷新
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
startAutoRefresh();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
130
templates/login.html
Normal file
130
templates/login.html
Normal file
@@ -0,0 +1,130 @@
|
||||
<!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 {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
animation: fadeInUp 0.5s ease-out;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.form-group button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.form-group button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 10px 15px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<h1>📱 短信转发接收端</h1>
|
||||
{{if .error}}
|
||||
<div class="error-message">{{.error}}</div>
|
||||
{{end}}
|
||||
<form method="POST" action="/login">
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<input type="text" id="username" name="username" required placeholder="请输入用户名">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<input type="password" id="password" name="password" required placeholder="请输入密码">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="submit">登录</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
371
templates/logs.html
Normal file
371
templates/logs.html
Normal file
@@ -0,0 +1,371 @@
|
||||
<!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);
|
||||
animation: slideInDown 0.5s ease-out;
|
||||
}
|
||||
|
||||
.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 a.active {
|
||||
background: #764ba2;
|
||||
}
|
||||
|
||||
.nav .logout {
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
.nav .logout:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
animation: fadeInUp 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.auto-refresh-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.refresh-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 10px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.refresh-toggle.active {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.logs-list {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
animation: fadeInUp 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
.logs-list table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.logs-list th, .logs-list td {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.logs-list th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.logs-list tr:hover {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-badge.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.sign-badge {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
animation: fadeInUp 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin-bottom: 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from { opacity: 0; transform: translateY(-30px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header { flex-direction: column; gap: 15px; }
|
||||
.nav { justify-content: center; }
|
||||
.toolbar { flex-direction: column; align-items: stretch; }
|
||||
.logs-list { overflow-x: auto; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📋 接收日志</h1>
|
||||
<div class="nav">
|
||||
<a href="/">短信列表</a>
|
||||
<a href="/logs" class="active">接收日志</a>
|
||||
<a href="/statistics">统计信息</a>
|
||||
<a href="/logout" class="logout">退出登录</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<span style="color: #666;">自动刷新</span>
|
||||
<div class="auto-refresh-info">
|
||||
<span class="refresh-toggle active" id="refreshToggle">
|
||||
<input type="checkbox" id="autoRefresh" checked onchange="toggleAutoRefresh()">
|
||||
<span>启用</span>
|
||||
</span>
|
||||
<span id="refreshCountdown">30s</span>
|
||||
</div>
|
||||
<div class="refresh-btn">
|
||||
<button onclick="location.reload()">立即刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logs-list">
|
||||
{{if gt (len .logs) 0}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>号码</th>
|
||||
<th>内容</th>
|
||||
<th>时间</th>
|
||||
<th>签名</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .logs}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.FromNumber}}</td>
|
||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{.Content}}</td>
|
||||
<td>{{.CreatedAt.Format "2006-01-02 15:04:05"}}</td>
|
||||
<td>
|
||||
{{if .SignValid.Valid}}
|
||||
{{if .SignValid.Bool}}
|
||||
<span class="sign-badge">✅</span>
|
||||
{{else}}
|
||||
<span class="sign-badge">❌</span>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<span style="color: #999;">-</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if eq .Status "success"}}
|
||||
<span class="status-badge success">成功</span>
|
||||
{{else}}
|
||||
<span class="status-badge error">失败</span>
|
||||
{{if .ErrorMessage.Valid}}
|
||||
<br><small style="color: #e74c3c;">{{.ErrorMessage.String}}</small>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<h3>暂无日志</h3>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if gt .totalPages 1}}
|
||||
<div class="pagination">
|
||||
{{if gt .page 1}}
|
||||
<a href="/logs?page={{sub .page 1}}">上一页</a>
|
||||
{{end}}
|
||||
|
||||
{{range $p := (seq 1 .totalPages)}}
|
||||
{{if eq $p $.page}}
|
||||
<span class="active">{{$p}}</span>
|
||||
{{else if or (le $p 3) (ge $p (sub $.totalPages 2)) (and (ge $p (sub $.page 1)) (le $p (add $.page 1)))}}
|
||||
<a href="/logs?page={{$p}}">{{$p}}</a>
|
||||
{{else if or (eq $p 4) (eq $p (sub $.totalPages 2))}}
|
||||
<span>...</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if lt .page .totalPages}}
|
||||
<a href="/logs?page={{add .page 1}}">下一页</a>
|
||||
{{end}}
|
||||
|
||||
<span>共 {{.total}} 条,第 {{.page}} / {{.totalPages}} 页</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let refreshInterval;
|
||||
let countdownInterval;
|
||||
let refreshCountdown = 30;
|
||||
const REFRESH_INTERVAL = 30;
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
const checkbox = document.getElementById('autoRefresh');
|
||||
const toggle = document.getElementById('refreshToggle');
|
||||
if (checkbox.checked) {
|
||||
toggle.classList.add('active');
|
||||
startAutoRefresh();
|
||||
} else {
|
||||
toggle.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';
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('autoRefresh').addEventListener('change', toggleAutoRefresh);
|
||||
startAutoRefresh();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
92
templates/message_detail.html
Normal file
92
templates/message_detail.html
Normal file
@@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>短信详情 - 短信转发接收端</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<nav class="navbar">
|
||||
<div class="nav-brand">📱 短信转发接收端</div>
|
||||
<div class="nav-links">
|
||||
<a href="/">短信列表</a>
|
||||
<a href="/logs">接收日志</a>
|
||||
<a href="/statistics">统计信息</a>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="content">
|
||||
<div class="detail-container">
|
||||
<h2>📱 短信详情</h2>
|
||||
<a href="/" style="color: #667eea; text-decoration: none;">← 返回列表</a>
|
||||
|
||||
<div class="detail-item" style="margin-top: 20px;">
|
||||
<div class="detail-label">ID</div>
|
||||
<div class="detail-value">{{.ID}}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">发送方号码</div>
|
||||
<div class="detail-value">{{.FromNumber}}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">短信内容</div>
|
||||
<div class="detail-value" style="white-space: pre-wrap;">{{.Content}}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">原始时间戳</div>
|
||||
<div class="detail-value">{{.Timestamp}}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">本地时间</div>
|
||||
<div class="detail-value">{{.TimestampStr}}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">入库时间</div>
|
||||
<div class="detail-value">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">签名验证</div>
|
||||
<div class="detail-value">
|
||||
{{if .SignVerified.Valid}}
|
||||
{{if .SignVerified.Bool}}
|
||||
<span class="badge badge-success">已验证</span>
|
||||
{{else}}
|
||||
<span class="badge badge-danger">未验证</span>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<span class="badge badge-warning">未验证</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .DeviceInfo.Valid}}
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">设备信息</div>
|
||||
<div class="detail-value">{{.DeviceInfo.String}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .SIMInfo.Valid}}
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">SIM 卡信息</div>
|
||||
<div class="detail-value">{{.SIMInfo.String}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">IP 地址</div>
|
||||
<div class="detail-value">{{.IPAddress}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
284
templates/statistics.html
Normal file
284
templates/statistics.html
Normal file
@@ -0,0 +1,284 @@
|
||||
<!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);
|
||||
animation: slideInDown 0.5s ease-out;
|
||||
}
|
||||
|
||||
.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 a.active {
|
||||
background: #764ba2;
|
||||
}
|
||||
|
||||
.nav .logout {
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
.nav .logout:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
animation: fadeInUp 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.stats .stat-card:nth-child(1) { animation-delay: 0.1s; }
|
||||
.stats .stat-card:nth-child(2) { animation-delay: 0.2s; }
|
||||
.stats .stat-card:nth-child(3) { animation-delay: 0.3s; }
|
||||
.stats .stat-card:nth-child(4) { animation-delay: 0.4s; }
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 30px 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-card.blue { border-top: 4px solid #667eea; }
|
||||
.stat-card.green { border-top: 4px solid #38ef7d; }
|
||||
.stat-card.orange { border-top: 4px solid #f5576c; }
|
||||
.stat-card.purple { border-top: 4px solid #764ba2; }
|
||||
|
||||
.detail-section {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
animation: fadeInUp 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
.detail-section h2 {
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.detail-section table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.detail-section td {
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.detail-section td:first-child {
|
||||
color: #666;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.detail-section tr:hover {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.progress-bar .fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from { opacity: 0; transform: translateY(-30px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header { flex-direction: column; gap: 15px; }
|
||||
.nav { justify-content: center; }
|
||||
.stats { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📊 统计信息</h1>
|
||||
<div class="nav">
|
||||
<a href="/">短信列表</a>
|
||||
<a href="/logs">接收日志</a>
|
||||
<a href="/statistics" class="active">统计信息</a>
|
||||
<a href="/logout" class="logout">退出登录</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card blue">
|
||||
<h3>短信总数</h3>
|
||||
<div class="value">{{.stats.Total}}</div>
|
||||
</div>
|
||||
<div class="stat-card green">
|
||||
<h3>今日短信</h3>
|
||||
<div class="value">{{.stats.Today}}</div>
|
||||
</div>
|
||||
<div class="stat-card orange">
|
||||
<h3>本周短信</h3>
|
||||
<div class="value">{{.stats.Week}}</div>
|
||||
</div>
|
||||
<div class="stat-card purple">
|
||||
<h3>签名验证</h3>
|
||||
<div class="value">{{add .stats.Verified .stats.Unverified}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h2>签名验证详情</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<td>已验证签名</td>
|
||||
<td>
|
||||
<span class="badge badge-success">{{.stats.Verified}} 条</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>未验证签名</td>
|
||||
<td>
|
||||
{{if .stats.Unverified}}
|
||||
<span class="badge badge-warning">{{.stats.Unverified}} 条</span>
|
||||
{{else}}
|
||||
<span class="badge badge-success">0 条</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>验证通过率</td>
|
||||
<td>
|
||||
{{if .stats.Total}}
|
||||
{{if .stats.Verified}}
|
||||
<span class="badge badge-success">{{printf "%.1f" (mulFloat .stats.Verified .stats.Total)}}%</span>
|
||||
{{else}}
|
||||
<span class="badge badge-warning">0%</span>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<span style="color: #999;">N/A</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{{if .stats.Total}}
|
||||
{{$verified := .stats.Verified}}
|
||||
{{$total := .stats.Total}}
|
||||
<div class="progress-bar">
|
||||
<div class="fill" style="width: {{mulFloat $verified $total}}%"></div>
|
||||
</div>
|
||||
<p style="margin-top: 10px; color: #666; font-size: 14px;">
|
||||
已验证 {{.stats.Verified}} / 总数 {{.stats.Total}}
|
||||
</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
11
test_api.sh
Executable file
11
test_api.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
# 测试 Go 版本 API
|
||||
|
||||
echo "测试 API /api/messages..."
|
||||
curl -s http://127.0.0.1:28001/api/messages | python3 -m json.tool | head -30
|
||||
|
||||
echo -e "\n\n测试 API /api/statistics..."
|
||||
curl -s http://127.0.0.1:28001/api/statistics | python3 -m json.tool
|
||||
|
||||
echo -e "\n\n测试首页 / (先登录获取 cookie)"
|
||||
# 这里需要手动登录测试
|
||||
Reference in New Issue
Block a user