Initial commit: 91 API Server
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Binary
|
||||
api-server
|
||||
|
||||
# Runtime data
|
||||
data/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
211
README.md
Normal file
211
README.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# api-server
|
||||
|
||||
基于 Go + Gin 的 API 服务平台,将 [sol148-extractor](../sol148-extractor) 的功能封装为 RESTful API,内置统计分析监控和嵌入式视频浏览前端。
|
||||
|
||||
---
|
||||
|
||||
## 三层架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 前端(嵌入式 SPA) │
|
||||
│ / 运维仪表盘(Chart.js 暗色主题) │
|
||||
│ /video 视频网站(浏览/搜索/提取/作者/分类) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 核心业务 API(sol148-extractor 功能) │
|
||||
│ POST /api/extract 视频源提取 │
|
||||
│ POST /api/extract/batch 批量提取(并发可控) │
|
||||
│ GET /api/categories 分类目录 │
|
||||
│ GET /api/category/:code 分类视频列表(分页/全量) │
|
||||
│ GET /api/author/:uid 作者视频列表(分页/全量) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 统计监控 API(运维用,监控业务 API) │
|
||||
│ GET /api/stats/overview 总览 │
|
||||
│ GET /api/stats/requests 时间序列 │
|
||||
│ GET /api/stats/geo 地理分布(ip2region) │
|
||||
│ GET /api/stats/concurrency 并发统计 │
|
||||
│ GET /api/stats/latency 延迟百分位 │
|
||||
│ GET /api/stats/status 状态码分布 │
|
||||
│ GET /api/stats/endpoints 热门端点 │
|
||||
│ GET /api/stats/top-ips 高频 IP │
|
||||
│ GET /api/stats/realtime 实时统计 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 内部引擎(Go 原生实现) │
|
||||
│ scraper/ Session 预热 + 正则提取(Python → Go 移植) │
|
||||
│ analytics/ 线程安全统计引擎 + ip2region │
|
||||
│ middleware/ Gin 中间件自动采集请求 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
api-server/
|
||||
├── main.go # CLI 入口
|
||||
├── cmd/
|
||||
│ ├── root.go # cobra 根命令 + viper
|
||||
│ └── serve.go # serve 子命令
|
||||
├── internal/
|
||||
│ ├── scraper/ # 核心提取引擎(sol148-extractor 的 Go 移植)
|
||||
│ │ ├── session.go # HTTP Session 预热(反爬 Cookie)
|
||||
│ │ ├── extractor.go # 视频源提取(strencode2 → unescape → mp4)
|
||||
│ │ ├── author.go # 作者视频列表提取
|
||||
│ │ └── category.go # 分类视频列表提取
|
||||
│ ├── handler/
|
||||
│ │ ├── extract.go # POST /api/extract, /api/extract/batch
|
||||
│ │ ├── content.go # GET /api/categories, /category/:code, /author/:uid
|
||||
│ │ ├── stats.go # GET /api/stats/*
|
||||
│ │ └── handler.go # /api/health, /api/ping
|
||||
│ ├── analytics/
|
||||
│ │ ├── engine.go # 线程安全统计引擎
|
||||
│ │ └── geoip.go # ip2region 地理位置
|
||||
│ ├── middleware/analytics.go # Gin 中间件(请求采集)
|
||||
│ ├── config/config.go # 配置管理
|
||||
│ ├── model/stats.go # 数据模型
|
||||
│ └── server/server.go # 路由注册 + 嵌入式前端
|
||||
├── web/
|
||||
│ ├── embed.go # //go:embed 嵌入
|
||||
│ └── static/
|
||||
│ ├── index.html + app.js + style.css # 运维仪表盘
|
||||
│ └── videoapp/
|
||||
│ ├── index.html # 视频网站 SPA
|
||||
│ ├── app.js # 路由/API 调用/渲染
|
||||
│ └── style.css # 视频站主题
|
||||
├── data/ # ip2region.xdb 数据库
|
||||
└── go.mod
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心业务 API
|
||||
|
||||
### 视频源提取
|
||||
|
||||
```bash
|
||||
# 单个提取
|
||||
curl -X POST http://localhost:8080/api/extract \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"url":"https://h1014.sol148.com/view_video.php?viewkey=xxx"}'
|
||||
|
||||
# 批量提取(并发度可配)
|
||||
curl -X POST http://localhost:8080/api/extract/batch \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"urls":["url1","url2","url3"], "concurrency":3}'
|
||||
```
|
||||
|
||||
响应示例:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"url": "原始 URL",
|
||||
"video_url": "https://cdn.xxx/.../12345.mp4?st=...",
|
||||
"video_id": "12345",
|
||||
"title": "视频标题",
|
||||
"duration": "00:09:29",
|
||||
"views": 65979,
|
||||
"favorites": 1213,
|
||||
"author_name": "作者名",
|
||||
"author_url": "https://h1014.sol148.com/uprofile.php?UID=xxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 分类浏览
|
||||
|
||||
```bash
|
||||
# 分类列表
|
||||
curl http://localhost:8080/api/categories
|
||||
|
||||
# 分类视频(分页)
|
||||
curl "http://localhost:8080/api/category/rf?page=1&viewtype=basic"
|
||||
|
||||
# 全量获取
|
||||
curl "http://localhost:8080/api/category/top?all=true&max_pages=5"
|
||||
```
|
||||
|
||||
### 作者浏览
|
||||
|
||||
```bash
|
||||
# 作者视频(分页)
|
||||
curl "http://localhost:8080/api/author/kBBKslfHMfbdEHb5SeiLocPIbBow7t6?page=1"
|
||||
|
||||
# 全量获取
|
||||
curl "http://localhost:8080/api/author/kBBKslfHMfbdEHb5SeiLocPIbBow7t6?all=true"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 视频网站前端 (`/video`)
|
||||
|
||||
一个完整功能的单页应用,直观展示和测试所有 API:
|
||||
|
||||
| 页面 | 路由 | 调用的 API |
|
||||
|------|------|-----------|
|
||||
| 首页 | `#/home` | `/categories`, `/stats/overview` |
|
||||
| 分类浏览 | `#/browse` | `/categories`, `/category/:code` |
|
||||
| 分类列表 | `#/categories` | `/categories` |
|
||||
| 视频详情 | `#/watch/:viewkey` | `/extract`, `/category/:code` |
|
||||
| 作者页 | `#/author/:uid` | `/author/:uid` |
|
||||
| 搜索 | `#/search` | `/category/:code` (全量 + 前端过滤) |
|
||||
|
||||
特色功能:
|
||||
- 导航栏实时显示 QPS / 活跃连接 / 延迟(来自 `/stats/realtime`)
|
||||
- 视频详情页自动调用提取 API 获取视频源
|
||||
- 一键复制视频源地址
|
||||
- 分类分页浏览
|
||||
- 客户端搜索过滤
|
||||
- 暗色主题,响应式布局
|
||||
|
||||
---
|
||||
|
||||
## 设计对应关系(sol148-extractor → api-server)
|
||||
|
||||
| sol148-extractor (Python) | api-server (Go) |
|
||||
|---------------------------|-----------------|
|
||||
| `extract.py` — 视频源提取 | `scraper/extractor.go` + `POST /api/extract` |
|
||||
| `extract_author.py` — 作者列表 | `scraper/author.go` + `GET /api/author/:uid` |
|
||||
| `crawl_category.py` — 分类列表 | `scraper/category.go` + `GET /api/category/:code` |
|
||||
| `argparse` CLI | `cobra` + `viper` |
|
||||
| `requests.Session` 预热 | `scraper/session.go` — HTTP Cookie Jar + 自动预热 |
|
||||
| `strencode2 → unescape → regex` | Go `url.QueryUnescape` + `regexp` |
|
||||
| JSON / URL-only 输出 | RESTful JSON API |
|
||||
| `-i urls.txt -o results.json` | `POST /extract/batch` 并发提取 |
|
||||
| `--delay` 页间延迟 | HTTP 无状态,按需调用 |
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 编译
|
||||
go build -o api-server .
|
||||
|
||||
# 启动(无需 ip2region 也可运行)
|
||||
./api-server serve
|
||||
|
||||
# 带 IP 地理位置
|
||||
./api-server serve --geoip --geoip-db ./data/ip2region.xdb
|
||||
|
||||
# 开发模式
|
||||
./api-server serve --mode debug --port 9090
|
||||
```
|
||||
|
||||
```bash
|
||||
# 访问
|
||||
http://localhost:8080/ # 运维仪表盘
|
||||
http://localhost:8080/video # 视频网站
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Go 1.22+** — 高性能编译型语言
|
||||
- **Gin** — HTTP 框架
|
||||
- **Cobra + Viper** — CLI + 配置
|
||||
- **ip2region** — IP 地理位置
|
||||
- **Chart.js** — 前端图表
|
||||
- **//go:embed** — 资源嵌入
|
||||
75
cmd/root.go
Normal file
75
cmd/root.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var cfgFile string
|
||||
|
||||
// rootCmd 根命令
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "api-server",
|
||||
Short: "高性能 API 服务,内置统计分析与运维监控",
|
||||
Long: `api-server 是一个基于 Gin 框架的高性能 API 服务程序。
|
||||
它内置了请求统计、IP 地理位置分析、并发监控等功能,
|
||||
并通过嵌入式前端提供可视化仪表盘。
|
||||
|
||||
参考设计:sol148-extractor(模块化、CLI 驱动、Session 管理)`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// 默认显示帮助
|
||||
if len(args) == 0 {
|
||||
cmd.Help()
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Execute 执行 CLI
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
cobra.OnInitialize(initConfig)
|
||||
|
||||
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "配置文件路径 (YAML/JSON)")
|
||||
rootCmd.PersistentFlags().String("host", "0.0.0.0", "监听地址")
|
||||
rootCmd.PersistentFlags().IntP("port", "p", 8080, "监听端口")
|
||||
rootCmd.PersistentFlags().String("mode", "release", "运行模式 (debug/release/test)")
|
||||
rootCmd.PersistentFlags().Bool("geoip", false, "启用 IP 地理位置解析")
|
||||
rootCmd.PersistentFlags().String("geoip-db", "./data/ip2region.xdb", "ip2region 数据库路径")
|
||||
|
||||
viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("host"))
|
||||
viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port"))
|
||||
viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode"))
|
||||
viper.BindPFlag("geoip", rootCmd.PersistentFlags().Lookup("geoip"))
|
||||
viper.BindPFlag("geoip_db", rootCmd.PersistentFlags().Lookup("geoip-db"))
|
||||
|
||||
// 添加子命令
|
||||
rootCmd.AddCommand(serveCmd)
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
if cfgFile != "" {
|
||||
viper.SetConfigFile(cfgFile)
|
||||
} else {
|
||||
viper.SetConfigName("config")
|
||||
viper.SetConfigType("yaml")
|
||||
viper.AddConfigPath(".")
|
||||
viper.AddConfigPath("./config")
|
||||
}
|
||||
|
||||
viper.AutomaticEnv()
|
||||
|
||||
if err := viper.ReadInConfig(); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "使用配置文件: %s\n", viper.ConfigFileUsed())
|
||||
}
|
||||
}
|
||||
57
cmd/serve.go
Normal file
57
cmd/serve.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"api-server/internal/config"
|
||||
"api-server/internal/server"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// serveCmd 启动 HTTP 服务
|
||||
var serveCmd = &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "启动 HTTP API 服务",
|
||||
Long: `启动 HTTP API 服务,包括:
|
||||
- RESTful API 接口
|
||||
- 统计分析 API(/api/stats/*)
|
||||
- 嵌入式监控仪表盘(/)
|
||||
- 请求日志与并发监控`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println(`
|
||||
╔══════════════════════════════════════╗
|
||||
║ api-server - 量化级 API 服务 ║
|
||||
║ Go + Gin + Cobra + ip2region ║
|
||||
╚══════════════════════════════════════╝`)
|
||||
|
||||
// 加载配置
|
||||
cfg := config.Load(viper.GetViper())
|
||||
|
||||
if cfg.Server.Mode == "debug" {
|
||||
log.Println("[config] 运行模式: debug")
|
||||
}
|
||||
|
||||
// 创建并启动服务
|
||||
srv, err := server.New(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建服务失败: %w", err)
|
||||
}
|
||||
|
||||
return srv.Run()
|
||||
},
|
||||
}
|
||||
|
||||
// versionCmd 版本信息
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "显示版本信息",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("api-server v1.0.0")
|
||||
fmt.Println(" Go: go1.22+")
|
||||
fmt.Println(" Web: Gin + embedded single-page app")
|
||||
fmt.Println(" Stats: ip2region + in-memory analytics engine")
|
||||
},
|
||||
}
|
||||
50
go.mod
Normal file
50
go.mod
Normal file
@@ -0,0 +1,50 @@
|
||||
module api-server
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.12.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260609051939-e360f3d0315b // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // 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/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spf13/viper v1.21.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
110
go.sum
Normal file
110
go.sum
Normal file
@@ -0,0 +1,110 @@
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
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/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260609051939-e360f3d0315b h1:NLXReseDanLoFX7P+Qhghvyoi7t2wuT8HQI0NUK61zM=
|
||||
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260609051939-e360f3d0315b/go.mod h1:sj5LMpsqB4IWdwIrcmmBJM6m+rW/uOQLSGUPhKkqdh8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
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/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
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/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
628
internal/analytics/engine.go
Normal file
628
internal/analytics/engine.go
Normal file
@@ -0,0 +1,628 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"log"
|
||||
"math"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"api-server/internal/model"
|
||||
)
|
||||
|
||||
// Engine 统计分析引擎(线程安全)
|
||||
type Engine struct {
|
||||
mu sync.RWMutex
|
||||
geoip *GeoIP
|
||||
startTime time.Time
|
||||
|
||||
// 请求记录(环形缓冲区思想,简单用切片 + 定期清理)
|
||||
records []model.RequestRecord
|
||||
maxRecs int
|
||||
|
||||
// 并发跟踪
|
||||
activeConns int64 // 当前活跃连接(原子操作)
|
||||
peakConns int64 // 历史峰值
|
||||
connSamples []model.ConcurrencySample // 并发采样
|
||||
maxSamples int
|
||||
|
||||
// 聚合计数器
|
||||
totalRequests int64 // 总请求数(原子)
|
||||
totalErrors int64 // 总错误数(原子)
|
||||
totalTraffic int64 // 总流量字节(原子)
|
||||
|
||||
// IP 计数器
|
||||
ipCount map[string]int64
|
||||
|
||||
// 端点计数器: "METHOD /path" -> count+latency
|
||||
endpointStats map[string]*endpointAgg
|
||||
|
||||
// 停止信号
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
type endpointAgg struct {
|
||||
Count int64
|
||||
TotalLatency time.Duration
|
||||
Errors int64
|
||||
}
|
||||
|
||||
// NewEngine 创建统计引擎
|
||||
func NewEngine(geo *GeoIP) *Engine {
|
||||
e := &Engine{
|
||||
geoip: geo,
|
||||
startTime: time.Now(),
|
||||
records: make([]model.RequestRecord, 0, 100000),
|
||||
maxRecs: 200000,
|
||||
maxSamples: 720, // 默认保留720个采样点(每小时一个,保留30天)
|
||||
connSamples: make([]model.ConcurrencySample, 0, 720),
|
||||
ipCount: make(map[string]int64),
|
||||
endpointStats: make(map[string]*endpointAgg),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
// 启动定期清理
|
||||
go e.cleanupLoop()
|
||||
// 启动并发采样
|
||||
go e.concurrencySampler()
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// ── 数据写入 ──────────────────────────────────────────────────
|
||||
|
||||
// Record 记录一条请求
|
||||
func (e *Engine) Record(r model.RequestRecord) {
|
||||
// 并发计数
|
||||
atomic.AddInt64(&e.totalRequests, 1)
|
||||
if r.StatusCode >= 400 {
|
||||
atomic.AddInt64(&e.totalErrors, 1)
|
||||
}
|
||||
atomic.AddInt64(&e.totalTraffic, r.BodyBytes)
|
||||
|
||||
// IP 地理信息
|
||||
if e.geoip != nil {
|
||||
info := e.geoip.Lookup(r.ClientIP)
|
||||
r.Region = info.Region
|
||||
r.ISP = info.ISP
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
// 存入记录
|
||||
if len(e.records) < e.maxRecs {
|
||||
e.records = append(e.records, r)
|
||||
}
|
||||
|
||||
// IP 计数
|
||||
e.ipCount[r.ClientIP]++
|
||||
|
||||
// 端点统计
|
||||
key := r.Method + " " + r.Path
|
||||
ep, ok := e.endpointStats[key]
|
||||
if !ok {
|
||||
ep = &endpointAgg{}
|
||||
e.endpointStats[key] = ep
|
||||
}
|
||||
ep.Count++
|
||||
ep.TotalLatency += r.Latency
|
||||
if r.StatusCode >= 400 {
|
||||
ep.Errors++
|
||||
}
|
||||
}
|
||||
|
||||
// ── 并发管理 ──────────────────────────────────────────────────
|
||||
|
||||
// ConnEnter 连接进入
|
||||
func (e *Engine) ConnEnter() {
|
||||
cur := atomic.AddInt64(&e.activeConns, 1)
|
||||
// 更新峰值(非精确,竞态可接受)
|
||||
for {
|
||||
peak := atomic.LoadInt64(&e.peakConns)
|
||||
if cur <= peak || atomic.CompareAndSwapInt64(&e.peakConns, peak, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ConnLeave 连接离开
|
||||
func (e *Engine) ConnLeave() {
|
||||
atomic.AddInt64(&e.activeConns, -1)
|
||||
}
|
||||
|
||||
// concurrencySampler 定期采样并发数
|
||||
func (e *Engine) concurrencySampler() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-e.stopCh:
|
||||
return
|
||||
case t := <-ticker.C:
|
||||
e.mu.Lock()
|
||||
cur := atomic.LoadInt64(&e.activeConns)
|
||||
s := model.ConcurrencySample{
|
||||
Time: t.Format("15:04:05"),
|
||||
Value: cur,
|
||||
}
|
||||
if len(e.connSamples) >= e.maxSamples {
|
||||
e.connSamples = e.connSamples[1:]
|
||||
}
|
||||
e.connSamples = append(e.connSamples, s)
|
||||
e.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 清理 ──────────────────────────────────────────────────────
|
||||
|
||||
func (e *Engine) cleanupLoop() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-e.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
e.cleanup(time.Now().Add(-7 * 24 * time.Hour))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanup 清理过期记录
|
||||
func (e *Engine) cleanup(before time.Time) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
cutoff := 0
|
||||
for i, r := range e.records {
|
||||
if r.Timestamp.After(before) {
|
||||
cutoff = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if cutoff > 0 {
|
||||
e.records = e.records[cutoff:]
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 停止引擎
|
||||
func (e *Engine) Stop() {
|
||||
close(e.stopCh)
|
||||
}
|
||||
|
||||
// ── 查询 API ──────────────────────────────────────────────────
|
||||
|
||||
// Overview 总览统计
|
||||
func (e *Engine) Overview() model.OverviewStats {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
total := atomic.LoadInt64(&e.totalRequests)
|
||||
errors := atomic.LoadInt64(&e.totalErrors)
|
||||
active := atomic.LoadInt64(&e.activeConns)
|
||||
traffic := atomic.LoadInt64(&e.totalTraffic)
|
||||
|
||||
// 平均延迟 & QPS
|
||||
avgLat := 0.0
|
||||
qps := 0.0
|
||||
uptime := time.Since(e.startTime)
|
||||
if uptime.Seconds() > 0 {
|
||||
qps = float64(total) / uptime.Seconds()
|
||||
}
|
||||
|
||||
recs := e.recentRecords(time.Minute)
|
||||
if len(recs) > 0 {
|
||||
var sum time.Duration
|
||||
for _, r := range recs {
|
||||
sum += r.Latency
|
||||
}
|
||||
avgLat = float64(sum.Microseconds()) / float64(len(recs)) / 1000.0
|
||||
}
|
||||
|
||||
lastReqTime := time.Time{}
|
||||
if len(e.records) > 0 {
|
||||
lastReqTime = e.records[len(e.records)-1].Timestamp
|
||||
}
|
||||
|
||||
errRate := 0.0
|
||||
if total > 0 {
|
||||
errRate = float64(errors) / float64(total) * 100
|
||||
}
|
||||
|
||||
uniqueIPs := int64(len(e.ipCount))
|
||||
|
||||
return model.OverviewStats{
|
||||
TotalRequests: total,
|
||||
ActiveConn: active,
|
||||
AvgLatency: math.Round(avgLat*100) / 100,
|
||||
ErrorRate: math.Round(errRate*100) / 100,
|
||||
RequestsPerSec: math.Round(qps*100) / 100,
|
||||
Uptime: uptime,
|
||||
UniqueIPs: uniqueIPs,
|
||||
TotalTraffic: traffic / (1024 * 1024),
|
||||
LastRequestTime: lastReqTime,
|
||||
}
|
||||
}
|
||||
|
||||
// TimeSeries 时间序列数据
|
||||
func (e *Engine) TimeSeries(period time.Duration, buckets int) []model.TimeSeriesPoint {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
if buckets <= 0 {
|
||||
buckets = 60
|
||||
}
|
||||
now := time.Now()
|
||||
start := now.Add(-period)
|
||||
interval := period / time.Duration(buckets)
|
||||
|
||||
// 过滤时间段内的记录
|
||||
recs := e.recordsInRange(start, now)
|
||||
if len(recs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
bucketsData := make([]struct {
|
||||
count int
|
||||
errors int
|
||||
latSum time.Duration
|
||||
}, buckets)
|
||||
|
||||
for _, r := range recs {
|
||||
idx := int(r.Timestamp.Sub(start) / interval)
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
if idx >= buckets {
|
||||
idx = buckets - 1
|
||||
}
|
||||
bucketsData[idx].count++
|
||||
if r.StatusCode >= 400 {
|
||||
bucketsData[idx].errors++
|
||||
}
|
||||
bucketsData[idx].latSum += r.Latency
|
||||
}
|
||||
|
||||
result := make([]model.TimeSeriesPoint, buckets)
|
||||
for i := 0; i < buckets; i++ {
|
||||
t := start.Add(interval * time.Duration(i))
|
||||
b := bucketsData[i]
|
||||
avgMs := 0.0
|
||||
if b.count > 0 {
|
||||
avgMs = float64(b.latSum.Microseconds()) / float64(b.count) / 1000.0
|
||||
}
|
||||
result[i] = model.TimeSeriesPoint{
|
||||
Time: t.Format("15:04"),
|
||||
Count: int64(b.count),
|
||||
Errors: int64(b.errors),
|
||||
AvgMs: math.Round(avgMs*100) / 100,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GeoDistribution 地理分布 Top N
|
||||
func (e *Engine) GeoDistribution(topN int) []model.GeoDistribution {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
regionCount := make(map[string]int64)
|
||||
|
||||
// 只统计最近1小时
|
||||
cutoff := time.Now().Add(-1 * time.Hour)
|
||||
for _, r := range e.records {
|
||||
if r.Timestamp.After(cutoff) {
|
||||
regionCount[r.Region]++
|
||||
}
|
||||
}
|
||||
|
||||
total := int64(0)
|
||||
for _, c := range regionCount {
|
||||
total += c
|
||||
}
|
||||
|
||||
type kv struct {
|
||||
k string
|
||||
v int64
|
||||
}
|
||||
var sorted []kv
|
||||
for k, v := range regionCount {
|
||||
sorted = append(sorted, kv{k, v})
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].v > sorted[j].v })
|
||||
|
||||
if topN > 0 && len(sorted) > topN {
|
||||
sorted = sorted[:topN]
|
||||
}
|
||||
|
||||
result := make([]model.GeoDistribution, len(sorted))
|
||||
for i, kv := range sorted {
|
||||
pct := 0.0
|
||||
if total > 0 {
|
||||
pct = float64(kv.v) / float64(total) * 100
|
||||
}
|
||||
result[i] = model.GeoDistribution{
|
||||
Region: kv.k,
|
||||
Count: kv.v,
|
||||
Pct: math.Round(pct*100) / 100,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Concurrency 并发统计
|
||||
func (e *Engine) Concurrency() model.ConcurrencyStats {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
cur := atomic.LoadInt64(&e.activeConns)
|
||||
peak := atomic.LoadInt64(&e.peakConns)
|
||||
|
||||
avg := 0.0
|
||||
if len(e.connSamples) > 0 {
|
||||
var sum int64
|
||||
for _, s := range e.connSamples {
|
||||
sum += s.Value
|
||||
}
|
||||
avg = float64(sum) / float64(len(e.connSamples))
|
||||
}
|
||||
|
||||
return model.ConcurrencyStats{
|
||||
Current: cur,
|
||||
Peak: peak,
|
||||
Avg: math.Round(avg*100) / 100,
|
||||
Samples: e.connSamples,
|
||||
}
|
||||
}
|
||||
|
||||
// Latency 延迟统计
|
||||
func (e *Engine) Latency() model.LatencyStats {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
recs := e.recentRecords(5 * time.Minute)
|
||||
if len(recs) == 0 {
|
||||
return model.LatencyStats{}
|
||||
}
|
||||
|
||||
latencies := make([]float64, len(recs))
|
||||
var sum, maxVal, minVal float64
|
||||
minVal = math.MaxFloat64
|
||||
for i, r := range recs {
|
||||
ms := float64(r.Latency.Microseconds()) / 1000.0
|
||||
latencies[i] = ms
|
||||
sum += ms
|
||||
if ms > maxVal {
|
||||
maxVal = ms
|
||||
}
|
||||
if ms < minVal {
|
||||
minVal = ms
|
||||
}
|
||||
}
|
||||
|
||||
sort.Float64s(latencies)
|
||||
n := len(latencies)
|
||||
|
||||
percentile := func(p float64) float64 {
|
||||
idx := int(math.Ceil(p*float64(n))) - 1
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
if idx >= n {
|
||||
idx = n - 1
|
||||
}
|
||||
return latencies[idx]
|
||||
}
|
||||
|
||||
return model.LatencyStats{
|
||||
Avg: math.Round(sum/float64(n)*100) / 100,
|
||||
P50: math.Round(percentile(0.50)*100) / 100,
|
||||
P90: math.Round(percentile(0.90)*100) / 100,
|
||||
P95: math.Round(percentile(0.95)*100) / 100,
|
||||
P99: math.Round(percentile(0.99)*100) / 100,
|
||||
Max: math.Round(maxVal*100) / 100,
|
||||
Min: math.Round(minVal*100) / 100,
|
||||
Count: int64(n),
|
||||
}
|
||||
}
|
||||
|
||||
// StatusDist 状态码分布
|
||||
func (e *Engine) StatusDist() []model.StatusDistribution {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
counts := map[string]int64{"2xx": 0, "3xx": 0, "4xx": 0, "5xx": 0}
|
||||
var total int64
|
||||
|
||||
recs := e.recentRecords(time.Hour)
|
||||
for _, r := range recs {
|
||||
switch {
|
||||
case r.StatusCode >= 500:
|
||||
counts["5xx"]++
|
||||
case r.StatusCode >= 400:
|
||||
counts["4xx"]++
|
||||
case r.StatusCode >= 300:
|
||||
counts["3xx"]++
|
||||
default:
|
||||
counts["2xx"]++
|
||||
}
|
||||
total++
|
||||
}
|
||||
|
||||
order := []string{"2xx", "3xx", "4xx", "5xx"}
|
||||
result := make([]model.StatusDistribution, 0, 4)
|
||||
for _, code := range order {
|
||||
ct := counts[code]
|
||||
if ct > 0 || total > 0 {
|
||||
pct := 0.0
|
||||
if total > 0 {
|
||||
pct = float64(ct) / float64(total) * 100
|
||||
}
|
||||
result = append(result, model.StatusDistribution{
|
||||
Code: code,
|
||||
Count: ct,
|
||||
Pct: math.Round(pct*100) / 100,
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// TopEndpoints Top N 端点
|
||||
func (e *Engine) TopEndpoints(topN int) []model.EndpointStats {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
type kv struct {
|
||||
k string
|
||||
v *endpointAgg
|
||||
}
|
||||
var pairs []kv
|
||||
for k, v := range e.endpointStats {
|
||||
pairs = append(pairs, kv{k, v})
|
||||
}
|
||||
sort.Slice(pairs, func(i, j int) bool { return pairs[i].v.Count > pairs[j].v.Count })
|
||||
|
||||
if topN > 0 && len(pairs) > topN {
|
||||
pairs = pairs[:topN]
|
||||
}
|
||||
|
||||
result := make([]model.EndpointStats, len(pairs))
|
||||
for i, p := range pairs {
|
||||
avgLat := 0.0
|
||||
if p.v.Count > 0 {
|
||||
avgLat = float64(p.v.TotalLatency.Microseconds()) / float64(p.v.Count) / 1000.0
|
||||
}
|
||||
errRate := 0.0
|
||||
if p.v.Count > 0 {
|
||||
errRate = float64(p.v.Errors) / float64(p.v.Count) * 100
|
||||
}
|
||||
result[i] = model.EndpointStats{
|
||||
Path: p.k,
|
||||
Count: p.v.Count,
|
||||
AvgLatency: math.Round(avgLat*100) / 100,
|
||||
ErrorRate: math.Round(errRate*100) / 100,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// TopIPs Top N IP
|
||||
func (e *Engine) TopIPs(topN int) []model.TopIP {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
type kv struct {
|
||||
ip string
|
||||
ct int64
|
||||
}
|
||||
var pairs []kv
|
||||
for ip, ct := range e.ipCount {
|
||||
pairs = append(pairs, kv{ip, ct})
|
||||
}
|
||||
sort.Slice(pairs, func(i, j int) bool { return pairs[i].ct > pairs[j].ct })
|
||||
|
||||
if topN > 0 && len(pairs) > topN {
|
||||
pairs = pairs[:topN]
|
||||
}
|
||||
|
||||
result := make([]model.TopIP, len(pairs))
|
||||
for i, p := range pairs {
|
||||
region := "未知"
|
||||
if e.geoip != nil {
|
||||
region = e.geoip.Lookup(p.ip).Region
|
||||
}
|
||||
result[i] = model.TopIP{
|
||||
IP: p.ip,
|
||||
Region: region,
|
||||
Count: p.ct,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Realtime 实时统计
|
||||
func (e *Engine) Realtime() model.RealtimeStats {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
active := atomic.LoadInt64(&e.activeConns)
|
||||
recs := e.recentRecords(time.Minute)
|
||||
|
||||
var totalLatMs float64
|
||||
errors := int64(0)
|
||||
for _, r := range recs {
|
||||
totalLatMs += float64(r.Latency.Microseconds()) / 1000.0
|
||||
if r.StatusCode >= 400 {
|
||||
errors++
|
||||
}
|
||||
}
|
||||
|
||||
avgLat := 0.0
|
||||
if len(recs) > 0 {
|
||||
avgLat = totalLatMs / float64(len(recs))
|
||||
}
|
||||
|
||||
qps := float64(len(recs)) / 60.0
|
||||
|
||||
errRate := 0.0
|
||||
if len(recs) > 0 {
|
||||
errRate = float64(errors) / float64(len(recs)) * 100
|
||||
}
|
||||
|
||||
return model.RealtimeStats{
|
||||
QPS: math.Round(qps*100) / 100,
|
||||
ActiveConn: active,
|
||||
AvgLatencyMs: math.Round(avgLat*100) / 100,
|
||||
LastMinuteCount: int64(len(recs)),
|
||||
ErrorRate: math.Round(errRate*100) / 100,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 内部辅助 ──────────────────────────────────────────────────
|
||||
|
||||
// recentRecords 获取最近 duration 内的记录(调用方需持有锁)
|
||||
func (e *Engine) recentRecords(d time.Duration) []model.RequestRecord {
|
||||
cutoff := time.Now().Add(-d)
|
||||
for i := len(e.records) - 1; i >= 0; i-- {
|
||||
if e.records[i].Timestamp.Before(cutoff) {
|
||||
return e.records[i+1:]
|
||||
}
|
||||
}
|
||||
return e.records
|
||||
}
|
||||
|
||||
// recordsInRange 获取时间范围内的记录(调用方需持有锁)
|
||||
func (e *Engine) recordsInRange(start, end time.Time) []model.RequestRecord {
|
||||
if len(e.records) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 二分查找起始位置
|
||||
startIdx := sort.Search(len(e.records), func(i int) bool {
|
||||
return !e.records[i].Timestamp.Before(start)
|
||||
})
|
||||
if startIdx >= len(e.records) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 收集范围内的记录
|
||||
var result []model.RequestRecord
|
||||
for i := startIdx; i < len(e.records); i++ {
|
||||
if e.records[i].Timestamp.After(end) {
|
||||
break
|
||||
}
|
||||
result = append(result, e.records[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// LogStats 定期输出统计摘要到日志
|
||||
func (e *Engine) LogStats() {
|
||||
o := e.Overview()
|
||||
log.Printf("[stats] 总请求=%d | 活跃=%d | QPS=%.1f | 错误率=%.1f%% | 延迟=%.1fms | 独立IP=%d",
|
||||
o.TotalRequests, o.ActiveConn, o.RequestsPerSec, o.ErrorRate, o.AvgLatency, o.UniqueIPs)
|
||||
}
|
||||
140
internal/analytics/geoip.go
Normal file
140
internal/analytics/geoip.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/lionsoul2014/ip2region/binding/golang/xdb"
|
||||
)
|
||||
|
||||
// GeoIP 基于 ip2region 的 IP 地理位置解析器
|
||||
type GeoIP struct {
|
||||
mu sync.RWMutex
|
||||
searcher *xdb.Searcher
|
||||
enabled bool
|
||||
cache map[string]*GeoInfo
|
||||
}
|
||||
|
||||
// GeoInfo IP 地理位置信息
|
||||
type GeoInfo struct {
|
||||
Country string
|
||||
Province string
|
||||
City string
|
||||
ISP string
|
||||
Region string
|
||||
}
|
||||
|
||||
// NewGeoIP 创建解析器,dbPath 是 ip2region.xdb 路径
|
||||
func NewGeoIP(dbPath string, enableCache bool) (*GeoIP, error) {
|
||||
g := &GeoIP{
|
||||
enabled: true,
|
||||
cache: make(map[string]*GeoInfo),
|
||||
}
|
||||
|
||||
searcher, err := xdb.NewWithFileOnly(xdb.IPv4, dbPath)
|
||||
if err != nil {
|
||||
log.Printf("[geoip] 无法加载 %s: %v,禁用 IP 解析", dbPath, err)
|
||||
g.enabled = false
|
||||
return g, nil
|
||||
}
|
||||
g.searcher = searcher
|
||||
log.Printf("[geoip] 已加载 IP 地理位置库: %s", dbPath)
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// Lookup 查询 IP 对应的地理位置
|
||||
func (g *GeoIP) Lookup(ip string) *GeoInfo {
|
||||
if !g.enabled || g.searcher == nil {
|
||||
return &GeoInfo{Region: "未知"}
|
||||
}
|
||||
|
||||
if isPrivateIP(ip) {
|
||||
return &GeoInfo{Region: "内网", Country: "内网", Province: "内网", City: "内网"}
|
||||
}
|
||||
|
||||
g.mu.RLock()
|
||||
if info, ok := g.cache[ip]; ok {
|
||||
g.mu.RUnlock()
|
||||
return info
|
||||
}
|
||||
g.mu.RUnlock()
|
||||
|
||||
region, err := g.searcher.Search(ip)
|
||||
if err != nil {
|
||||
return &GeoInfo{Region: "未知"}
|
||||
}
|
||||
|
||||
info := parseRegion(region)
|
||||
|
||||
g.mu.Lock()
|
||||
g.cache[ip] = info
|
||||
if len(g.cache) > 50000 {
|
||||
g.cache = make(map[string]*GeoInfo)
|
||||
}
|
||||
g.mu.Unlock()
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// parseRegion 解析 ip2region 返回的 "国家|区域|省份|城市|ISP" 格式
|
||||
func parseRegion(raw string) *GeoInfo {
|
||||
parts := strings.Split(raw, "|")
|
||||
if len(parts) < 5 {
|
||||
return &GeoInfo{Region: raw}
|
||||
}
|
||||
|
||||
info := &GeoInfo{
|
||||
Country: parts[0],
|
||||
Province: parts[2],
|
||||
City: parts[3],
|
||||
ISP: parts[4],
|
||||
}
|
||||
|
||||
regionParts := []string{}
|
||||
if parts[0] != "0" && parts[0] != "" {
|
||||
regionParts = append(regionParts, parts[0])
|
||||
}
|
||||
if parts[2] != "0" && parts[2] != "" {
|
||||
regionParts = append(regionParts, parts[2])
|
||||
}
|
||||
if parts[3] != "0" && parts[3] != "" {
|
||||
regionParts = append(regionParts, parts[3])
|
||||
}
|
||||
if len(regionParts) == 0 {
|
||||
info.Region = "未知"
|
||||
} else {
|
||||
info.Region = strings.Join(regionParts, "-")
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// isPrivateIP 判断是否为内网 IP
|
||||
func isPrivateIP(ip string) bool {
|
||||
if strings.HasPrefix(ip, "10.") ||
|
||||
strings.HasPrefix(ip, "172.16.") ||
|
||||
strings.HasPrefix(ip, "192.168.") ||
|
||||
strings.HasPrefix(ip, "127.") ||
|
||||
ip == "::1" ||
|
||||
strings.HasPrefix(ip, "fe80:") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Close 释放资源
|
||||
func (g *GeoIP) Close() {
|
||||
if g.searcher != nil {
|
||||
g.searcher.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// FormatRegion 用于日志/展示的短格式
|
||||
func (gi *GeoInfo) FormatRegion() string {
|
||||
if gi.Region == "内网" || gi.Region == "未知" {
|
||||
return gi.Region
|
||||
}
|
||||
return fmt.Sprintf("%s [%s]", gi.Region, gi.ISP)
|
||||
}
|
||||
87
internal/config/config.go
Normal file
87
internal/config/config.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Config 全局配置
|
||||
type Config struct {
|
||||
Server ServerConfig
|
||||
Analytics AnalyticsConfig
|
||||
GeoIP GeoIPConfig
|
||||
}
|
||||
|
||||
// ServerConfig HTTP 服务配置
|
||||
type ServerConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
Mode string // debug / release / test
|
||||
}
|
||||
|
||||
// AnalyticsConfig 统计配置
|
||||
type AnalyticsConfig struct {
|
||||
Retention time.Duration
|
||||
CleanupInterval time.Duration
|
||||
Buckets int // 时间序列分桶数
|
||||
}
|
||||
|
||||
// GeoIPConfig IP 地理位置配置
|
||||
type GeoIPConfig struct {
|
||||
Enabled bool
|
||||
DBPath string
|
||||
}
|
||||
|
||||
// Default 默认配置
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
Server: ServerConfig{
|
||||
Host: "0.0.0.0",
|
||||
Port: 8080,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
Mode: "release",
|
||||
},
|
||||
Analytics: AnalyticsConfig{
|
||||
Retention: 7 * 24 * time.Hour,
|
||||
CleanupInterval: 10 * time.Minute,
|
||||
Buckets: 60,
|
||||
},
|
||||
GeoIP: GeoIPConfig{
|
||||
Enabled: true,
|
||||
DBPath: "./data/ip2region.xdb",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load 从 viper 加载,合并默认值
|
||||
func Load(v *viper.Viper) *Config {
|
||||
cfg := Default()
|
||||
|
||||
if v.IsSet("host") {
|
||||
cfg.Server.Host = v.GetString("host")
|
||||
}
|
||||
if v.IsSet("port") {
|
||||
cfg.Server.Port = v.GetInt("port")
|
||||
}
|
||||
if v.IsSet("mode") {
|
||||
cfg.Server.Mode = v.GetString("mode")
|
||||
}
|
||||
if v.IsSet("retention") {
|
||||
cfg.Analytics.Retention = v.GetDuration("retention")
|
||||
}
|
||||
if v.IsSet("buckets") {
|
||||
cfg.Analytics.Buckets = v.GetInt("buckets")
|
||||
}
|
||||
if v.IsSet("geoip") {
|
||||
cfg.GeoIP.Enabled = v.GetBool("geoip")
|
||||
}
|
||||
if v.IsSet("geoip_db") {
|
||||
cfg.GeoIP.DBPath = v.GetString("geoip_db")
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
114
internal/handler/content.go
Normal file
114
internal/handler/content.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"api-server/internal/scraper"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ContentHandler 内容列表 API 处理器
|
||||
type ContentHandler struct {
|
||||
authorSession *scraper.Session
|
||||
categorySession *scraper.Session
|
||||
}
|
||||
|
||||
// NewContentHandler 创建内容处理器
|
||||
func NewContentHandler() *ContentHandler {
|
||||
return &ContentHandler{
|
||||
authorSession: scraper.NewSession(),
|
||||
categorySession: scraper.NewSession(),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册路由
|
||||
func (h *ContentHandler) RegisterRoutes(r *gin.RouterGroup) {
|
||||
r.GET("/categories", h.ListCategories)
|
||||
r.GET("/category/:code", h.CategoryVideos)
|
||||
r.GET("/author/:uid", h.AuthorVideos)
|
||||
}
|
||||
|
||||
// ListCategories GET /api/categories
|
||||
func (h *ContentHandler) ListCategories(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"data": scraper.Categories,
|
||||
})
|
||||
}
|
||||
|
||||
// CategoryVideos GET /api/category/:code?page=1&viewtype=basic&max_pages=0
|
||||
func (h *ContentHandler) CategoryVideos(c *gin.Context) {
|
||||
code := c.Param("code")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
viewtype := c.DefaultQuery("viewtype", "basic")
|
||||
maxPages := scraper.ParseMaxPages(c.DefaultQuery("max_pages", "0"))
|
||||
allMode := c.Query("all") == "true"
|
||||
|
||||
// 验证分类代码
|
||||
valid := false
|
||||
for _, cat := range scraper.Categories {
|
||||
if cat.Code == code {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 1,
|
||||
"msg": "无效的分类代码,可用: rf, top, ori, hot, long, longer, tf, hd, md, mf",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var result *scraper.CategoryResult
|
||||
var err error
|
||||
|
||||
if allMode || maxPages > 1 {
|
||||
result, err = scraper.ExtractCategoryAll(h.categorySession, code, viewtype, maxPages)
|
||||
} else {
|
||||
result, err = scraper.ExtractCategory(h.categorySession, code, page, viewtype)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 1,
|
||||
"msg": "提取失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": result})
|
||||
}
|
||||
|
||||
// AuthorVideos GET /api/author/:uid?page=1&all=false
|
||||
func (h *ContentHandler) AuthorVideos(c *gin.Context) {
|
||||
uid := c.Param("uid")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
allMode := c.Query("all") == "true"
|
||||
|
||||
if uid == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请提供作者 UID"})
|
||||
return
|
||||
}
|
||||
|
||||
var result *scraper.AuthorResult
|
||||
var err error
|
||||
|
||||
if allMode {
|
||||
result, err = scraper.ExtractAuthorAll(h.authorSession, uid)
|
||||
} else {
|
||||
result, err = scraper.ExtractAuthor(h.authorSession, uid, page)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 1,
|
||||
"msg": "提取失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": result})
|
||||
}
|
||||
116
internal/handler/extract.go
Normal file
116
internal/handler/extract.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"api-server/internal/scraper"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ExtractHandler 视频提取 API 处理器
|
||||
type ExtractHandler struct {
|
||||
session *scraper.Session
|
||||
}
|
||||
|
||||
// NewExtractHandler 创建提取处理器
|
||||
func NewExtractHandler() *ExtractHandler {
|
||||
return &ExtractHandler{
|
||||
session: scraper.NewSession(),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册路由
|
||||
func (h *ExtractHandler) RegisterRoutes(r *gin.RouterGroup) {
|
||||
r.POST("/extract", h.ExtractSingle)
|
||||
r.POST("/extract/batch", h.ExtractBatch)
|
||||
}
|
||||
|
||||
// ExtractSingle POST /api/extract
|
||||
// Body: {"url": "https://h1014.sol148.com/view_video.php?viewkey=xxx"}
|
||||
func (h *ExtractHandler) ExtractSingle(c *gin.Context) {
|
||||
var req struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请提供视频 URL"})
|
||||
return
|
||||
}
|
||||
|
||||
info, err := scraper.ExtractVideo(h.session, req.URL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 1,
|
||||
"msg": "提取失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": info})
|
||||
}
|
||||
|
||||
// ExtractBatch POST /api/extract/batch
|
||||
// Body: {"urls": ["url1", "url2", ...], "concurrency": 3}
|
||||
func (h *ExtractHandler) ExtractBatch(c *gin.Context) {
|
||||
var req struct {
|
||||
URLs []string `json:"urls" binding:"required"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请提供 URL 列表"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Concurrency <= 0 {
|
||||
req.Concurrency = 3
|
||||
}
|
||||
if req.Concurrency > 10 {
|
||||
req.Concurrency = 10
|
||||
}
|
||||
|
||||
type result struct {
|
||||
URL string `json:"url"`
|
||||
Data *scraper.VideoInfo `json:"data,omitempty"`
|
||||
Err string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
results := make([]result, len(req.URLs))
|
||||
sem := make(chan struct{}, req.Concurrency)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, u := range req.URLs {
|
||||
wg.Add(1)
|
||||
go func(idx int, urlStr string) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
// 每个 goroutine 使用独立 session
|
||||
s := scraper.NewSession()
|
||||
info, err := scraper.ExtractVideo(s, strings.TrimSpace(urlStr))
|
||||
if err != nil {
|
||||
results[idx] = result{URL: urlStr, Err: err.Error()}
|
||||
} else {
|
||||
results[idx] = result{URL: urlStr, Data: info}
|
||||
}
|
||||
}(i, u)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
success := 0
|
||||
for _, r := range results {
|
||||
if r.Err == "" {
|
||||
success++
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"total": len(req.URLs),
|
||||
"success": success,
|
||||
"data": results,
|
||||
})
|
||||
}
|
||||
33
internal/handler/handler.go
Normal file
33
internal/handler/handler.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"api-server/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Handler 基础处理器
|
||||
type Handler struct {
|
||||
Cfg *config.Config
|
||||
}
|
||||
|
||||
// New 创建处理器
|
||||
func New(cfg *config.Config) *Handler {
|
||||
return &Handler{Cfg: cfg}
|
||||
}
|
||||
|
||||
// Health 健康检查
|
||||
func (h *Handler) Health(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"time": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// Ping 简单 ping
|
||||
func (h *Handler) Ping(c *gin.Context) {
|
||||
c.String(http.StatusOK, "pong")
|
||||
}
|
||||
144
internal/handler/stats.go
Normal file
144
internal/handler/stats.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"api-server/internal/analytics"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// StatsHandler 统计分析 API 处理器
|
||||
type StatsHandler struct {
|
||||
Engine *analytics.Engine
|
||||
}
|
||||
|
||||
// NewStats 创建统计处理器
|
||||
func NewStats(engine *analytics.Engine) *StatsHandler {
|
||||
return &StatsHandler{Engine: engine}
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册统计相关路由
|
||||
func (h *StatsHandler) RegisterRoutes(r *gin.RouterGroup) {
|
||||
stats := r.Group("/stats")
|
||||
{
|
||||
stats.GET("/overview", h.Overview)
|
||||
stats.GET("/requests", h.TimeSeries)
|
||||
stats.GET("/geo", h.GeoDistribution)
|
||||
stats.GET("/concurrency", h.Concurrency)
|
||||
stats.GET("/latency", h.Latency)
|
||||
stats.GET("/status", h.StatusDist)
|
||||
stats.GET("/endpoints", h.TopEndpoints)
|
||||
stats.GET("/top-ips", h.TopIPs)
|
||||
stats.GET("/realtime", h.Realtime)
|
||||
}
|
||||
}
|
||||
|
||||
// Overview 总览统计 GET /api/stats/overview
|
||||
func (h *StatsHandler) Overview(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"data": h.Engine.Overview(),
|
||||
})
|
||||
}
|
||||
|
||||
// TimeSeries 时间序列 GET /api/stats/requests?period=1h&buckets=60
|
||||
func (h *StatsHandler) TimeSeries(c *gin.Context) {
|
||||
period := parseDuration(c.DefaultQuery("period", "1h"))
|
||||
buckets, _ := strconv.Atoi(c.DefaultQuery("buckets", "60"))
|
||||
if buckets <= 0 || buckets > 500 {
|
||||
buckets = 60
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"data": h.Engine.TimeSeries(period, buckets),
|
||||
})
|
||||
}
|
||||
|
||||
// GeoDistribution 地理分布 GET /api/stats/geo?top=10
|
||||
func (h *StatsHandler) GeoDistribution(c *gin.Context) {
|
||||
top, _ := strconv.Atoi(c.DefaultQuery("top", "10"))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"data": h.Engine.GeoDistribution(top),
|
||||
})
|
||||
}
|
||||
|
||||
// Concurrency 并发统计 GET /api/stats/concurrency
|
||||
func (h *StatsHandler) Concurrency(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"data": h.Engine.Concurrency(),
|
||||
})
|
||||
}
|
||||
|
||||
// Latency 延迟统计 GET /api/stats/latency
|
||||
func (h *StatsHandler) Latency(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"data": h.Engine.Latency(),
|
||||
})
|
||||
}
|
||||
|
||||
// StatusDist 状态码分布 GET /api/stats/status
|
||||
func (h *StatsHandler) StatusDist(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"data": h.Engine.StatusDist(),
|
||||
})
|
||||
}
|
||||
|
||||
// TopEndpoints Top 端点 GET /api/stats/endpoints?top=10
|
||||
func (h *StatsHandler) TopEndpoints(c *gin.Context) {
|
||||
top, _ := strconv.Atoi(c.DefaultQuery("top", "10"))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"data": h.Engine.TopEndpoints(top),
|
||||
})
|
||||
}
|
||||
|
||||
// TopIPs Top IP GET /api/stats/top-ips?top=10
|
||||
func (h *StatsHandler) TopIPs(c *gin.Context) {
|
||||
top, _ := strconv.Atoi(c.DefaultQuery("top", "10"))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"data": h.Engine.TopIPs(top),
|
||||
})
|
||||
}
|
||||
|
||||
// Realtime 实时统计 GET /api/stats/realtime
|
||||
func (h *StatsHandler) Realtime(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"data": h.Engine.Realtime(),
|
||||
})
|
||||
}
|
||||
|
||||
// ── 辅助 ──────────────────────────────────────────────────────
|
||||
|
||||
func parseDuration(s string) time.Duration {
|
||||
// 支持: 1h, 24h, 7d, 30m, 1d
|
||||
if d, err := time.ParseDuration(s); err == nil {
|
||||
return d
|
||||
}
|
||||
// 自定义解析
|
||||
if len(s) >= 2 {
|
||||
val, err := strconv.Atoi(s[:len(s)-1])
|
||||
if err != nil {
|
||||
return time.Hour
|
||||
}
|
||||
unit := s[len(s)-1:]
|
||||
switch unit {
|
||||
case "d":
|
||||
return time.Duration(val) * 24 * time.Hour
|
||||
case "h":
|
||||
return time.Duration(val) * time.Hour
|
||||
case "m":
|
||||
return time.Duration(val) * time.Minute
|
||||
}
|
||||
}
|
||||
return time.Hour
|
||||
}
|
||||
44
internal/middleware/analytics.go
Normal file
44
internal/middleware/analytics.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"api-server/internal/analytics"
|
||||
"api-server/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Analytics 返回 Gin 中间件,用于采集请求统计
|
||||
func Analytics(engine *analytics.Engine) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 连接进入
|
||||
engine.ConnEnter()
|
||||
defer engine.ConnLeave()
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// 处理请求
|
||||
c.Next()
|
||||
|
||||
// 收集指标
|
||||
latency := time.Since(start)
|
||||
bodySize := c.Writer.Size()
|
||||
if bodySize < 0 {
|
||||
bodySize = 0
|
||||
}
|
||||
|
||||
record := model.RequestRecord{
|
||||
Timestamp: start,
|
||||
Method: c.Request.Method,
|
||||
Path: c.Request.URL.Path,
|
||||
StatusCode: c.Writer.Status(),
|
||||
Latency: latency,
|
||||
ClientIP: c.ClientIP(),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
BodyBytes: int64(bodySize),
|
||||
}
|
||||
|
||||
engine.Record(record)
|
||||
}
|
||||
}
|
||||
107
internal/model/stats.go
Normal file
107
internal/model/stats.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// ── 请求记录 ──────────────────────────────────────────────────
|
||||
|
||||
// RequestRecord 单次请求的完整记录
|
||||
type RequestRecord struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
StatusCode int `json:"status_code"`
|
||||
Latency time.Duration `json:"latency_ms"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Region string `json:"region"` // 国家|省份|城市
|
||||
ISP string `json:"isp"` // 运营商
|
||||
BodyBytes int64 `json:"body_bytes"` // 响应体大小
|
||||
}
|
||||
|
||||
// ── 统计 API 响应模型 ──────────────────────────────────────────
|
||||
|
||||
// OverviewStats 总览统计
|
||||
type OverviewStats struct {
|
||||
TotalRequests int64 `json:"total_requests"` // 总请求数
|
||||
ActiveConn int64 `json:"active_conn"` // 当前活跃连接数
|
||||
AvgLatency float64 `json:"avg_latency_ms"` // 平均延迟 (ms)
|
||||
ErrorRate float64 `json:"error_rate"` // 错误率 (4xx+5xx)
|
||||
RequestsPerSec float64 `json:"requests_per_sec"` // 每秒请求数 (QPS)
|
||||
Uptime time.Duration `json:"uptime_seconds"` // 运行时长 (秒)
|
||||
UniqueIPs int64 `json:"unique_ips"` // 独立 IP 数
|
||||
TotalTraffic int64 `json:"total_traffic_mb"` // 总流量 (MB)
|
||||
LastRequestTime time.Time `json:"last_request_time"` // 最近一次请求时间
|
||||
}
|
||||
|
||||
// TimeSeriesPoint 时间序列数据点
|
||||
type TimeSeriesPoint struct {
|
||||
Time string `json:"time"` // YYYY-MM-DD HH:MM
|
||||
Count int64 `json:"count"` // 请求数
|
||||
Errors int64 `json:"errors"` // 错误数
|
||||
AvgMs float64 `json:"avg_ms"` // 平均延迟
|
||||
}
|
||||
|
||||
// GeoDistribution 地理分布
|
||||
type GeoDistribution struct {
|
||||
Region string `json:"region"` // 区域名
|
||||
Count int64 `json:"count"` // 请求数
|
||||
Pct float64 `json:"pct"` // 占比
|
||||
}
|
||||
|
||||
// ConcurrencyStats 并发统计
|
||||
type ConcurrencyStats struct {
|
||||
Current int64 `json:"current"` // 当前并发
|
||||
Peak int64 `json:"peak"` // 历史峰值
|
||||
Avg float64 `json:"avg"` // 平均并发
|
||||
Samples []ConcurrencySample `json:"samples"` // 采样数据
|
||||
}
|
||||
|
||||
// ConcurrencySample 并发采样点
|
||||
type ConcurrencySample struct {
|
||||
Time string `json:"time"`
|
||||
Value int64 `json:"value"`
|
||||
}
|
||||
|
||||
// LatencyStats 延迟统计
|
||||
type LatencyStats struct {
|
||||
Avg float64 `json:"avg_ms"`
|
||||
P50 float64 `json:"p50_ms"`
|
||||
P90 float64 `json:"p90_ms"`
|
||||
P95 float64 `json:"p95_ms"`
|
||||
P99 float64 `json:"p99_ms"`
|
||||
Max float64 `json:"max_ms"`
|
||||
Min float64 `json:"min_ms"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// StatusDistribution 状态码分布
|
||||
type StatusDistribution struct {
|
||||
Code string `json:"code"` // "2xx", "3xx", "4xx", "5xx"
|
||||
Count int64 `json:"count"`
|
||||
Pct float64 `json:"pct"`
|
||||
}
|
||||
|
||||
// EndpointStats 端点统计
|
||||
type EndpointStats struct {
|
||||
Path string `json:"path"`
|
||||
Method string `json:"method"`
|
||||
Count int64 `json:"count"`
|
||||
AvgLatency float64 `json:"avg_latency_ms"`
|
||||
ErrorRate float64 `json:"error_rate"`
|
||||
}
|
||||
|
||||
// TopIP 高频 IP
|
||||
type TopIP struct {
|
||||
IP string `json:"ip"`
|
||||
Region string `json:"region"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// RealtimeStats 实时统计
|
||||
type RealtimeStats struct {
|
||||
QPS float64 `json:"qps"`
|
||||
ActiveConn int64 `json:"active_conn"`
|
||||
AvgLatencyMs float64 `json:"avg_latency_ms"`
|
||||
LastMinuteCount int64 `json:"last_minute_count"`
|
||||
ErrorRate float64 `json:"error_rate"`
|
||||
}
|
||||
182
internal/scraper/author.go
Normal file
182
internal/scraper/author.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package scraper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const PerPage = 8
|
||||
|
||||
// ── 正则 ────────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
reAuthorVideoLink = regexp.MustCompile(`<a\s+href="([^"]*view_video\.php\?own=1&viewkey=([^"&\s]+)[^"]*)"`)
|
||||
reAuthorThumb = regexp.MustCompile(`(?i)<img[^>]*src="([^"]+)"`)
|
||||
reAuthorDuration = regexp.MustCompile(`<span\s+class="duration"[^>]*>([\d:]+)</span>`)
|
||||
reAuthorTitle = regexp.MustCompile(`(?s)<span\s+class="video-title[^"]*"[^>]*>(.*?)</span>`)
|
||||
reAuthorWellBlock = regexp.MustCompile(`(?s)<div\s+class="well\s+well-sm">(.*?)</div>\s*</div>`)
|
||||
reAuthorViews = regexp.MustCompile(`(?i)<span\s+class="info">(?:热度|Views):</span>\s*(\d[\d,]*)`)
|
||||
reAuthorFavorites = regexp.MustCompile(`(?i)<span\s+class="info">(?:收藏|Favorites):</span>\s*(\d[\d,]*)`)
|
||||
reAuthorComments = regexp.MustCompile(`(?i)<span\s+class="info">(?:留言|Comments):</span>\s*(\d[\d,]*)`)
|
||||
reAuthorPoint = regexp.MustCompile(`(?i)<span\s+class="info">(?:积分|Point):</span>\s*(\d[\d,]*)`)
|
||||
reAuthorAdded = regexp.MustCompile(`(?i)<span\s+class="info">(?:添加时间|Added):</span>\s*([^<]+)`)
|
||||
reAuthorPageInfo = regexp.MustCompile(`视频\s*\d+-\d+\s*的\s*(\d+)`)
|
||||
reAuthorName = regexp.MustCompile(`(?i)<span\s+class="title[^"]*"[^>]*>\s*([^<\n]+)`)
|
||||
reAuthorTotalVids = regexp.MustCompile(`(?i)<small>\s*\((\d+)[^)]*\)\s*</small>`)
|
||||
)
|
||||
|
||||
// ── 数据模型 ────────────────────────────────────────────────
|
||||
|
||||
// AuthorVideoItem 作者视频条目
|
||||
type AuthorVideoItem struct {
|
||||
Viewkey string `json:"viewkey"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Duration string `json:"duration"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Views int64 `json:"views"`
|
||||
Favorites int64 `json:"favorites"`
|
||||
Comments int64 `json:"comments"`
|
||||
Point int64 `json:"point"`
|
||||
Added string `json:"added"`
|
||||
}
|
||||
|
||||
// AuthorResult 作者视频列表结果
|
||||
type AuthorResult struct {
|
||||
UID string `json:"uid"`
|
||||
Page int `json:"page"`
|
||||
AuthorName string `json:"author_name"`
|
||||
TotalVideos int `json:"total_videos"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
Items []AuthorVideoItem `json:"items"`
|
||||
}
|
||||
|
||||
// ── 提取器 ──────────────────────────────────────────────────
|
||||
|
||||
// ExtractAuthor 提取作者视频列表
|
||||
func ExtractAuthor(s *Session, uid string, page int) (*AuthorResult, error) {
|
||||
url := fmt.Sprintf("%s/uvideos.php?UID=%s&type=public&page=%d", BaseURL, uid, page)
|
||||
|
||||
resp, err := s.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
html := string(body)
|
||||
|
||||
result := &AuthorResult{
|
||||
UID: uid,
|
||||
Page: page,
|
||||
}
|
||||
|
||||
// 解析作者名
|
||||
if m := reAuthorName.FindStringSubmatch(html); m != nil {
|
||||
result.AuthorName = strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
// 解析总视频数
|
||||
if m := reAuthorTotalVids.FindStringSubmatch(html); m != nil {
|
||||
result.TotalVideos = int(parseInt(m[1]))
|
||||
}
|
||||
|
||||
// 解析分页信息
|
||||
if m := reAuthorPageInfo.FindStringSubmatch(html); m != nil {
|
||||
total := int(parseInt(m[1]))
|
||||
result.TotalPages = int(math.Ceil(float64(total) / PerPage))
|
||||
if result.TotalVideos == 0 {
|
||||
result.TotalVideos = total
|
||||
}
|
||||
} else if result.TotalVideos > 0 {
|
||||
result.TotalPages = int(math.Ceil(float64(result.TotalVideos) / PerPage))
|
||||
}
|
||||
|
||||
// 解析视频条目
|
||||
blocks := reAuthorWellBlock.FindAllStringSubmatch(html, -1)
|
||||
seen := make(map[string]bool)
|
||||
for _, block := range blocks {
|
||||
if len(block) < 2 {
|
||||
continue
|
||||
}
|
||||
item := parseAuthorItem(block[1])
|
||||
if item.Viewkey == "" || seen[item.Viewkey] {
|
||||
continue
|
||||
}
|
||||
seen[item.Viewkey] = true
|
||||
result.Items = append(result.Items, item)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ExtractAuthorAll 提取作者全部页面
|
||||
func ExtractAuthorAll(s *Session, uid string) (*AuthorResult, error) {
|
||||
// 先获取第1页来探测总页数
|
||||
first, err := ExtractAuthor(s, uid, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
totalPages := first.TotalPages
|
||||
if totalPages <= 1 {
|
||||
return first, nil
|
||||
}
|
||||
|
||||
// 收集剩余页面
|
||||
for p := 2; p <= totalPages; p++ {
|
||||
pageResult, err := ExtractAuthor(s, uid, p)
|
||||
if err != nil {
|
||||
// 容忍部分页面失败
|
||||
continue
|
||||
}
|
||||
first.Items = append(first.Items, pageResult.Items...)
|
||||
}
|
||||
|
||||
first.Page = 0 // 表示全量
|
||||
return first, nil
|
||||
}
|
||||
|
||||
func parseAuthorItem(html string) AuthorVideoItem {
|
||||
item := AuthorVideoItem{}
|
||||
|
||||
if m := reAuthorVideoLink.FindStringSubmatch(html); m != nil {
|
||||
item.URL = m[1]
|
||||
if !strings.HasPrefix(item.URL, "http") {
|
||||
item.URL = BaseURL + "/" + strings.TrimLeft(item.URL, "/")
|
||||
}
|
||||
item.Viewkey = m[2]
|
||||
}
|
||||
if m := reAuthorThumb.FindStringSubmatch(html); m != nil {
|
||||
item.Thumbnail = m[1]
|
||||
}
|
||||
if m := reAuthorDuration.FindStringSubmatch(html); m != nil {
|
||||
item.Duration = m[1]
|
||||
}
|
||||
if m := reAuthorTitle.FindStringSubmatch(html); m != nil {
|
||||
item.Title = strings.TrimSpace(m[1])
|
||||
}
|
||||
if m := reAuthorViews.FindStringSubmatch(html); m != nil {
|
||||
item.Views = parseInt(m[1])
|
||||
}
|
||||
if m := reAuthorFavorites.FindStringSubmatch(html); m != nil {
|
||||
item.Favorites = parseInt(m[1])
|
||||
}
|
||||
if m := reAuthorComments.FindStringSubmatch(html); m != nil {
|
||||
item.Comments = parseInt(m[1])
|
||||
}
|
||||
if m := reAuthorPoint.FindStringSubmatch(html); m != nil {
|
||||
item.Point = parseInt(m[1])
|
||||
}
|
||||
if m := reAuthorAdded.FindStringSubmatch(html); m != nil {
|
||||
item.Added = strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
244
internal/scraper/category.go
Normal file
244
internal/scraper/category.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package scraper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ── 分类定义 ────────────────────────────────────────────────
|
||||
|
||||
// CategoryInfo 分类信息
|
||||
type CategoryInfo struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Desc string `json:"desc"`
|
||||
}
|
||||
|
||||
// Categories 所有分类
|
||||
var Categories = []CategoryInfo{
|
||||
{Code: "rf", Name: "最近更新", Desc: "最新发布的视频"},
|
||||
{Code: "top", Name: "本月最热", Desc: "当月热度最高的视频"},
|
||||
{Code: "ori", Name: "原创", Desc: "原创内容"},
|
||||
{Code: "hot", Name: "收藏最多", Desc: "收藏数最高的视频"},
|
||||
{Code: "long", Name: "10分钟以上", Desc: "时长超过10分钟的长视频"},
|
||||
{Code: "longer", Name: "20分钟以上", Desc: "时长超过20分钟的超长视频"},
|
||||
{Code: "tf", Name: "最近讨论", Desc: "讨论度最高的视频"},
|
||||
{Code: "hd", Name: "高清", Desc: "高清晰度视频"},
|
||||
{Code: "md", Name: "每月讨论", Desc: "本月月度精选"},
|
||||
{Code: "mf", Name: "收藏最多(月)", Desc: "月度收藏排行榜"},
|
||||
}
|
||||
|
||||
// CategoryName 根据代码返回中文名
|
||||
func CategoryName(code string) string {
|
||||
for _, c := range Categories {
|
||||
if c.Code == code {
|
||||
return c.Name
|
||||
}
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// ── 正则 ────────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
reCatVideoLink = regexp.MustCompile(`(?s)<a\s+href="(https?://[^"]*view_video\.php\?viewkey=([^"&\s]+)[^"]*)"\s*>(.*?)</a>`)
|
||||
reCatThumb = regexp.MustCompile(`(?i)<img\s[^>]*src="([^"]+)"`)
|
||||
reCatDuration = regexp.MustCompile(`<span\s+class="duration"[^>]*>([\d:]+)</span>`)
|
||||
reCatTitle = regexp.MustCompile(`(?s)<span\s+class="video-title[^"]*"[^>]*>(.*?)</span>`)
|
||||
reCatWellStart = regexp.MustCompile(`<div\s+class="well\s+well-sm\s+videos-text-align">`)
|
||||
reCatAuthor = regexp.MustCompile(`(?i)<span\s+class="info">作者:</span>\s*([^<]+)`)
|
||||
reCatViews = regexp.MustCompile(`(?i)<span\s+class="info">热度:</span>\s*(\d[\d,]*)`)
|
||||
reCatFavorites = regexp.MustCompile(`(?i)<span\s+class="info">收藏:</span>\s*(\d[\d,]*)`)
|
||||
reCatComments = regexp.MustCompile(`(?i)<span\s+class="info">留言:</span>\s*(\d[\d,]*)`)
|
||||
reCatAdded = regexp.MustCompile(`(?i)<span\s+class="info">添加时间:</span>\s*([^<]+)`)
|
||||
reCatPageLinks = regexp.MustCompile(`page=(\d+)`)
|
||||
)
|
||||
|
||||
// ── 数据模型 ────────────────────────────────────────────────
|
||||
|
||||
// CategoryVideoItem 分类视频条目
|
||||
type CategoryVideoItem struct {
|
||||
Viewkey string `json:"viewkey"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Duration string `json:"duration"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Author string `json:"author"`
|
||||
Views int64 `json:"views"`
|
||||
Favorites int64 `json:"favorites"`
|
||||
Comments int64 `json:"comments"`
|
||||
Added string `json:"added"`
|
||||
}
|
||||
|
||||
// CategoryResult 分类视频列表结果
|
||||
type CategoryResult struct {
|
||||
Category string `json:"category"`
|
||||
Page int `json:"page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
Viewtype string `json:"viewtype"`
|
||||
Items []CategoryVideoItem `json:"items"`
|
||||
}
|
||||
|
||||
// ── 提取器 ──────────────────────────────────────────────────
|
||||
|
||||
// ExtractCategory 提取分类视频列表
|
||||
func ExtractCategory(s *Session, code string, page int, viewtype string) (*CategoryResult, error) {
|
||||
if viewtype == "" {
|
||||
viewtype = "basic"
|
||||
}
|
||||
url := fmt.Sprintf("%s/v.php?category=%s&viewtype=%s&page=%d", BaseURL, code, viewtype, page)
|
||||
|
||||
resp, err := s.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
html := string(body)
|
||||
|
||||
result := &CategoryResult{
|
||||
Category: code,
|
||||
Page: page,
|
||||
Viewtype: viewtype,
|
||||
}
|
||||
|
||||
// 探测总页数
|
||||
maxPage := 1
|
||||
for _, m := range reCatPageLinks.FindAllStringSubmatch(html, -1) {
|
||||
p := int(parseInt(m[1]))
|
||||
if p > maxPage {
|
||||
maxPage = p
|
||||
}
|
||||
}
|
||||
result.TotalPages = maxPage
|
||||
|
||||
// 解析视频条目 — 用 well 块起始标记切分 HTML
|
||||
blocks := splitWellBlocks(html)
|
||||
seen := make(map[string]bool)
|
||||
for _, block := range blocks {
|
||||
if len(block) < 50 {
|
||||
continue
|
||||
}
|
||||
item := parseCategoryItem(block)
|
||||
if item.Viewkey == "" || seen[item.Viewkey] {
|
||||
continue
|
||||
}
|
||||
seen[item.Viewkey] = true
|
||||
result.Items = append(result.Items, item)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ExtractCategoryAll 提取分类全部页面
|
||||
func ExtractCategoryAll(s *Session, code string, viewtype string, maxPages int) (*CategoryResult, error) {
|
||||
if viewtype == "" {
|
||||
viewtype = "basic"
|
||||
}
|
||||
|
||||
first, err := ExtractCategory(s, code, 1, viewtype)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
totalPages := first.TotalPages
|
||||
if maxPages > 0 && maxPages < totalPages {
|
||||
totalPages = maxPages
|
||||
}
|
||||
|
||||
if totalPages <= 1 {
|
||||
return first, nil
|
||||
}
|
||||
|
||||
for p := 2; p <= totalPages; p++ {
|
||||
pageResult, err := ExtractCategory(s, code, p, viewtype)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
first.Items = append(first.Items, pageResult.Items...)
|
||||
}
|
||||
|
||||
first.Page = 0
|
||||
return first, nil
|
||||
}
|
||||
|
||||
func parseCategoryItem(html string) CategoryVideoItem {
|
||||
item := CategoryVideoItem{}
|
||||
|
||||
if m := reCatVideoLink.FindStringSubmatch(html); m != nil {
|
||||
item.URL = m[1]
|
||||
if !strings.HasPrefix(item.URL, "http") {
|
||||
item.URL = BaseURL + "/" + strings.TrimLeft(item.URL, "/")
|
||||
}
|
||||
item.Viewkey = m[2]
|
||||
}
|
||||
if m := reCatThumb.FindStringSubmatch(html); m != nil {
|
||||
item.Thumbnail = m[1]
|
||||
}
|
||||
if m := reCatDuration.FindStringSubmatch(html); m != nil {
|
||||
item.Duration = m[1]
|
||||
}
|
||||
if m := reCatTitle.FindStringSubmatch(html); m != nil {
|
||||
item.Title = strings.TrimSpace(m[1])
|
||||
}
|
||||
if m := reCatAuthor.FindStringSubmatch(html); m != nil {
|
||||
item.Author = strings.TrimSpace(m[1])
|
||||
}
|
||||
if m := reCatViews.FindStringSubmatch(html); m != nil {
|
||||
item.Views = parseInt(m[1])
|
||||
}
|
||||
if m := reCatFavorites.FindStringSubmatch(html); m != nil {
|
||||
item.Favorites = parseInt(m[1])
|
||||
}
|
||||
if m := reCatComments.FindStringSubmatch(html); m != nil {
|
||||
item.Comments = parseInt(m[1])
|
||||
}
|
||||
if m := reCatAdded.FindStringSubmatch(html); m != nil {
|
||||
item.Added = strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
// ParseMaxPages 从查询参数解析最大页数限制
|
||||
func ParseMaxPages(s string) int {
|
||||
if s == "" || s == "all" || s == "0" {
|
||||
return 0
|
||||
}
|
||||
return int(parseInt(s))
|
||||
}
|
||||
|
||||
// splitWellBlocks 按 well 块起始标签切分 HTML,返回各块的内容
|
||||
func splitWellBlocks(html string) []string {
|
||||
var blocks []string
|
||||
// 找到所有 well 块的起始位置
|
||||
locs := reCatWellStart.FindAllStringIndex(html, -1)
|
||||
if len(locs) == 0 {
|
||||
return blocks
|
||||
}
|
||||
|
||||
// 找到分页区域的位置作为终止边界
|
||||
paginationStart := strings.Index(html, `<ul class="pagination`)
|
||||
if paginationStart < 0 {
|
||||
paginationStart = len(html)
|
||||
}
|
||||
|
||||
for i, loc := range locs {
|
||||
start := loc[1] // well 块起始标签之后
|
||||
end := paginationStart
|
||||
if i+1 < len(locs) {
|
||||
end = locs[i+1][0] // 下一个 well 块起始之前
|
||||
}
|
||||
if end > start {
|
||||
blocks = append(blocks, html[start:end])
|
||||
}
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
107
internal/scraper/extractor.go
Normal file
107
internal/scraper/extractor.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package scraper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ── 正则 ────────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
reStrencode2 = regexp.MustCompile(`strencode2\s*\(\s*["']([^"']+)["']`)
|
||||
reSourceSrc = regexp.MustCompile(`src\s*=\s*['"]([^'"]+\.mp4[^'"]*)['"]`)
|
||||
reVideoID = regexp.MustCompile(`/(\d+)\.mp4`)
|
||||
reTitle = regexp.MustCompile(`(?is)<title>\s*(.+?)\s*-\s*91porn\s*</title>`)
|
||||
reDuration = regexp.MustCompile(`时长:\s*<span[^>]*>([\d:]+)</span>`)
|
||||
reViews = regexp.MustCompile(`热度:\s*<span[^>]*>(\d[\d,]*)`)
|
||||
reFavorites = regexp.MustCompile(`收藏:\s*<span[^>]*>(\d[\d,]*)`)
|
||||
reAuthorLink = regexp.MustCompile(`(?i)<a\s+href\s*=\s*["']([^"']*uprofile\.php\?UID=[^"']+)["']\s*>\s*<span[^>]*>\s*([^<\n]+)`)
|
||||
)
|
||||
|
||||
// ── 数据模型 ────────────────────────────────────────────────
|
||||
|
||||
// VideoInfo 视频源提取结果
|
||||
type VideoInfo struct {
|
||||
URL string `json:"url"`
|
||||
VideoURL string `json:"video_url"`
|
||||
VideoID string `json:"video_id"`
|
||||
Title string `json:"title"`
|
||||
Duration string `json:"duration"`
|
||||
Views int64 `json:"views"`
|
||||
Favorites int64 `json:"favorites"`
|
||||
AuthorName string `json:"author_name"`
|
||||
AuthorURL string `json:"author_url"`
|
||||
}
|
||||
|
||||
// ── 提取器 ──────────────────────────────────────────────────
|
||||
|
||||
// ExtractVideo 从视频播放页 URL 提取正片源 + 元信息
|
||||
func ExtractVideo(s *Session, videoURL string) (*VideoInfo, error) {
|
||||
resp, err := s.Get(videoURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
html := string(body)
|
||||
|
||||
info := &VideoInfo{URL: videoURL}
|
||||
|
||||
// 提取视频源
|
||||
m := reStrencode2.FindStringSubmatch(html)
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("未找到 strencode2 调用")
|
||||
}
|
||||
decoded, err := url.QueryUnescape(m[1])
|
||||
if err != nil {
|
||||
decoded = m[1]
|
||||
}
|
||||
m2 := reSourceSrc.FindStringSubmatch(decoded)
|
||||
if m2 == nil {
|
||||
return nil, fmt.Errorf("解码后未找到视频源 <source>")
|
||||
}
|
||||
info.VideoURL = m2[1]
|
||||
|
||||
// 提取 video_id
|
||||
m3 := reVideoID.FindStringSubmatch(info.VideoURL)
|
||||
if m3 != nil {
|
||||
info.VideoID = m3[1]
|
||||
}
|
||||
|
||||
// 提取元信息
|
||||
if m := reTitle.FindStringSubmatch(html); m != nil {
|
||||
info.Title = strings.TrimSpace(m[1])
|
||||
}
|
||||
if m := reDuration.FindStringSubmatch(html); m != nil {
|
||||
info.Duration = m[1]
|
||||
}
|
||||
if m := reViews.FindStringSubmatch(html); m != nil {
|
||||
info.Views = parseInt(m[1])
|
||||
}
|
||||
if m := reFavorites.FindStringSubmatch(html); m != nil {
|
||||
info.Favorites = parseInt(m[1])
|
||||
}
|
||||
if m := reAuthorLink.FindStringSubmatch(html); m != nil {
|
||||
info.AuthorURL = m[1]
|
||||
if !strings.HasPrefix(info.AuthorURL, "http") {
|
||||
info.AuthorURL = BaseURL + "/" + strings.TrimLeft(info.AuthorURL, "/")
|
||||
}
|
||||
info.AuthorName = strings.TrimSpace(m[2])
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func parseInt(s string) int64 {
|
||||
s = strings.ReplaceAll(s, ",", "")
|
||||
v, _ := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
||||
return v
|
||||
}
|
||||
76
internal/scraper/session.go
Normal file
76
internal/scraper/session.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package scraper
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const BaseURL = "https://h1014.sol148.com"
|
||||
|
||||
var defaultHeaders = http.Header{
|
||||
"User-Agent": {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"},
|
||||
"Accept": {"text/html,application/xhtml+xml"},
|
||||
"Accept-Language": {"zh-CN,zh;q=0.9"},
|
||||
}
|
||||
|
||||
// Session 带预热机制的 HTTP 客户端(线程安全单例)
|
||||
type Session struct {
|
||||
mu sync.Mutex
|
||||
client *http.Client
|
||||
warmed bool
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewSession 创建 Session
|
||||
func NewSession() *Session {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &Session{
|
||||
client: &http.Client{
|
||||
Jar: jar,
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
timeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Get 执行 GET 请求,自动预热
|
||||
func (s *Session) Get(url string) (*http.Response, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.warmed {
|
||||
// 预热请求:获取 Cookie
|
||||
req, _ := http.NewRequest("GET", BaseURL+"/", nil)
|
||||
for k, vs := range defaultHeaders {
|
||||
for _, v := range vs {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
s.warmed = true
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, vs := range defaultHeaders {
|
||||
for _, v := range vs {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
return s.client.Do(req)
|
||||
}
|
||||
|
||||
// Reset 重置 Session(用于隔离不同来源的请求)
|
||||
func (s *Session) Reset() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.warmed = false
|
||||
}
|
||||
187
internal/server/server.go
Normal file
187
internal/server/server.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"api-server/internal/analytics"
|
||||
"api-server/internal/config"
|
||||
"api-server/internal/handler"
|
||||
"api-server/internal/middleware"
|
||||
"api-server/web"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Server HTTP 服务
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
engine *gin.Engine
|
||||
stats *analytics.Engine
|
||||
geoip *analytics.GeoIP
|
||||
srv *http.Server
|
||||
}
|
||||
|
||||
// New 创建服务实例
|
||||
func New(cfg *config.Config) (*Server, error) {
|
||||
// 设置 gin 模式
|
||||
gin.SetMode(cfg.Server.Mode)
|
||||
|
||||
// 初始化 IP 地理位置
|
||||
geoip, err := analytics.NewGeoIP(cfg.GeoIP.DBPath, true)
|
||||
if err != nil {
|
||||
log.Printf("[server] 初始化 GeoIP 失败: %v", err)
|
||||
geoip = nil
|
||||
}
|
||||
|
||||
// 初始化统计引擎
|
||||
statsEngine := analytics.NewEngine(geoip)
|
||||
|
||||
// 初始化 gin
|
||||
router := gin.New()
|
||||
|
||||
// 全局中间件
|
||||
router.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
|
||||
return fmt.Sprintf("[%s] %s %s %d %s %s\n",
|
||||
param.TimeStamp.Format("15:04:05"),
|
||||
param.ClientIP,
|
||||
param.Method,
|
||||
param.StatusCode,
|
||||
param.Latency,
|
||||
param.Path,
|
||||
)
|
||||
}))
|
||||
router.Use(gin.Recovery())
|
||||
router.Use(middleware.Analytics(statsEngine))
|
||||
|
||||
// ── 注册核心业务 API ──────────────────────────────────
|
||||
base := handler.New(cfg)
|
||||
api := router.Group("/api")
|
||||
{
|
||||
api.GET("/health", base.Health)
|
||||
api.GET("/ping", base.Ping)
|
||||
}
|
||||
|
||||
// sol148-extractor 业务 API
|
||||
extractHandler := handler.NewExtractHandler()
|
||||
extractHandler.RegisterRoutes(api)
|
||||
|
||||
contentHandler := handler.NewContentHandler()
|
||||
contentHandler.RegisterRoutes(api)
|
||||
|
||||
// 统计分析 API(监控用)
|
||||
statsHandler := handler.NewStats(statsEngine)
|
||||
statsHandler.RegisterRoutes(api)
|
||||
|
||||
// ── 嵌入式前端 ────────────────────────────────────────
|
||||
fileServer := http.FileServer(http.FS(web.StaticFS))
|
||||
|
||||
// Dashboard 首页
|
||||
router.GET("/", func(c *gin.Context) {
|
||||
c.Request.URL.Path = "/index.html"
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
|
||||
// 视频网站入口
|
||||
router.GET("/video", func(c *gin.Context) {
|
||||
c.Request.URL.Path = "/videoapp/index.html"
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
|
||||
// NoRoute: SPA fallback
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// 视频网站子路由 → videoapp/index.html
|
||||
if path == "/video" || strings.HasPrefix(path, "/video/") {
|
||||
c.Request.URL.Path = "/videoapp/index.html"
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试直接提供静态文件
|
||||
if path != "/" {
|
||||
trimmed := strings.TrimPrefix(path, "/")
|
||||
if f, err := web.StaticFS.Open(trimmed); err == nil {
|
||||
f.Close()
|
||||
c.Request.URL.Path = path
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:Dashboard SPA
|
||||
c.Request.URL.Path = "/index.html"
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port),
|
||||
Handler: router,
|
||||
ReadTimeout: cfg.Server.ReadTimeout,
|
||||
WriteTimeout: cfg.Server.WriteTimeout,
|
||||
}
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
engine: router,
|
||||
stats: statsEngine,
|
||||
geoip: geoip,
|
||||
srv: srv,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run 启动服务(阻塞直到收到信号)
|
||||
func (s *Server) Run() error {
|
||||
// 定期输出统计
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.stats.LogStats()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 启动 HTTP 服务
|
||||
go func() {
|
||||
log.Printf("🚀 服务启动: http://%s:%d", s.cfg.Server.Host, s.cfg.Server.Port)
|
||||
log.Printf("📊 统计面板: http://%s:%d/", s.cfg.Server.Host, s.cfg.Server.Port)
|
||||
log.Printf("🎬 视频网站: http://%s:%d/video", s.cfg.Server.Host, s.cfg.Server.Port)
|
||||
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("服务启动失败: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 等待退出信号
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
log.Println("正在关闭服务...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.srv.Shutdown(ctx); err != nil {
|
||||
return fmt.Errorf("服务关闭失败: %w", err)
|
||||
}
|
||||
|
||||
s.stats.Stop()
|
||||
if s.geoip != nil {
|
||||
s.geoip.Close()
|
||||
}
|
||||
|
||||
log.Println("服务已关闭")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
7
main.go
Normal file
7
main.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
import "api-server/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
}
|
||||
21
web/embed.go
Normal file
21
web/embed.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var embedFS embed.FS
|
||||
|
||||
// StaticFS 嵌入式前端静态文件(根路径已去除 static/ 前缀)
|
||||
var StaticFS fs.FS
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
StaticFS, err = fs.Sub(embedFS, "static")
|
||||
if err != nil {
|
||||
log.Fatalf("web: 无法挂载嵌入式文件系统: %v", err)
|
||||
}
|
||||
}
|
||||
341
web/static/app.js
Normal file
341
web/static/app.js
Normal file
@@ -0,0 +1,341 @@
|
||||
// ── 全局状态 ─────────────────────────────────────────────────
|
||||
const API = '/api/stats';
|
||||
let charts = {};
|
||||
let refreshTimer = null;
|
||||
|
||||
// ── 工具函数 ─────────────────────────────────────────────────
|
||||
async function fetchJSON(url) {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
return json.data || json;
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
const parts = [];
|
||||
if (d > 0) parts.push(`${d}d`);
|
||||
if (h > 0) parts.push(`${h}h`);
|
||||
if (m > 0) parts.push(`${m}m`);
|
||||
if (d === 0 && h === 0) parts.push(`${s}s`);
|
||||
return parts.join(' ') || '0s';
|
||||
}
|
||||
|
||||
function formatNum(n) {
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
|
||||
return '' + n;
|
||||
}
|
||||
|
||||
// ── 图表创建 ─────────────────────────────────────────────────
|
||||
function createOrUpdateChart(id, config) {
|
||||
if (charts[id]) {
|
||||
charts[id].data = config.data;
|
||||
charts[id].options = config.options || {};
|
||||
charts[id].update();
|
||||
} else {
|
||||
const ctx = document.getElementById(id).getContext('2d');
|
||||
charts[id] = new Chart(ctx, config);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 更新概览卡片 ────────────────────────────────────────────
|
||||
async function refreshOverview() {
|
||||
try {
|
||||
const data = await fetchJSON(API + '/overview');
|
||||
document.getElementById('totalRequests').textContent = formatNum(data.total_requests);
|
||||
document.getElementById('qps').textContent = data.requests_per_sec;
|
||||
document.getElementById('activeConn').textContent = data.active_conn;
|
||||
document.getElementById('avgLatency').textContent = data.avg_latency_ms;
|
||||
document.getElementById('errorRate').textContent = data.error_rate;
|
||||
document.getElementById('uniqueIPs').textContent = formatNum(data.unique_ips);
|
||||
|
||||
// 运行时间
|
||||
const uptime = data.uptime_seconds ? (data.uptime_seconds / 1e9) : 0;
|
||||
document.getElementById('uptime').textContent = '运行 ' + formatDuration(uptime);
|
||||
|
||||
// 状态指示
|
||||
const dot = document.getElementById('statusDot');
|
||||
const text = document.getElementById('statusText');
|
||||
dot.className = 'status-dot online';
|
||||
text.textContent = '运行中';
|
||||
} catch (e) {
|
||||
document.getElementById('statusDot').className = 'status-dot offline';
|
||||
document.getElementById('statusText').textContent = '离线';
|
||||
console.error('overview fetch error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── QPS 时间序列图 ──────────────────────────────────────────
|
||||
async function refreshTimeSeries() {
|
||||
const period = document.getElementById('periodSelect').value || '1h';
|
||||
try {
|
||||
const data = await fetchJSON(API + `/requests?period=${period}&buckets=60`);
|
||||
const labels = data.map(d => d.time);
|
||||
const counts = data.map(d => d.count);
|
||||
const errors = data.map(d => d.errors);
|
||||
|
||||
createOrUpdateChart('qpsChart', {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '请求数',
|
||||
data: counts,
|
||||
borderColor: '#6366f1',
|
||||
backgroundColor: 'rgba(99,102,241,0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
},
|
||||
{
|
||||
label: '错误数',
|
||||
data: errors,
|
||||
borderColor: '#ef4444',
|
||||
backgroundColor: 'rgba(239,68,68,0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { labels: { color: '#8b8d9a' } } },
|
||||
scales: {
|
||||
x: { ticks: { color: '#8b8d9a', maxTicksLimit: 12 }, grid: { color: '#2a2d3e' } },
|
||||
y: { ticks: { color: '#8b8d9a' }, grid: { color: '#2a2d3e' }, beginAtZero: true }
|
||||
},
|
||||
interaction: { intersect: false, mode: 'index' }
|
||||
}
|
||||
});
|
||||
} catch (e) { console.error('timeseries error:', e); }
|
||||
}
|
||||
|
||||
// ── 状态码分布图 ────────────────────────────────────────────
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const data = await fetchJSON(API + '/status');
|
||||
const labels = data.map(d => d.code);
|
||||
const values = data.map(d => d.count);
|
||||
const colors = {
|
||||
'2xx': '#10b981',
|
||||
'3xx': '#22d3ee',
|
||||
'4xx': '#f59e0b',
|
||||
'5xx': '#ef4444',
|
||||
};
|
||||
|
||||
createOrUpdateChart('statusChart', {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
data: values,
|
||||
backgroundColor: labels.map(l => colors[l] || '#6b7280'),
|
||||
borderWidth: 0,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'bottom', labels: { color: '#8b8d9a', padding: 16 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) { console.error('status error:', e); }
|
||||
}
|
||||
|
||||
// ── 并发连接图 ──────────────────────────────────────────────
|
||||
async function refreshConcurrency() {
|
||||
try {
|
||||
const data = await fetchJSON(API + '/concurrency');
|
||||
document.getElementById('activeConn').textContent = data.current;
|
||||
|
||||
const samples = data.samples || [];
|
||||
const labels = samples.map(s => s.time);
|
||||
const values = samples.map(s => s.value);
|
||||
|
||||
createOrUpdateChart('connChart', {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: '并发连接',
|
||||
data: values,
|
||||
borderColor: '#22d3ee',
|
||||
backgroundColor: 'rgba(34,211,238,0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { labels: { color: '#8b8d9a' } },
|
||||
annotation: data.peak ? {
|
||||
annotations: {
|
||||
peakLine: {
|
||||
type: 'line',
|
||||
yMin: data.peak,
|
||||
yMax: data.peak,
|
||||
borderColor: '#ef4444',
|
||||
borderDash: [4, 4],
|
||||
label: { content: '峰值 ' + data.peak, display: true }
|
||||
}
|
||||
}
|
||||
} : {}
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { color: '#8b8d9a', maxTicksLimit: 10 }, grid: { color: '#2a2d3e' } },
|
||||
y: { ticks: { color: '#8b8d9a' }, grid: { color: '#2a2d3e' }, beginAtZero: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) { console.error('concurrency error:', e); }
|
||||
}
|
||||
|
||||
// ── 延迟百分位图 ────────────────────────────────────────────
|
||||
async function refreshLatency() {
|
||||
try {
|
||||
const data = await fetchJSON(API + '/latency');
|
||||
const metrics = ['P50', 'P90', 'P95', 'P99', 'Avg', 'Max'];
|
||||
const values = [data.p50_ms, data.p90_ms, data.p95_ms, data.p99_ms, data.avg_ms, data.max_ms];
|
||||
const bgColors = ['#6366f1', '#6366f1', '#6366f1', '#f59e0b', '#10b981', '#ef4444'];
|
||||
|
||||
createOrUpdateChart('latencyChart', {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: metrics,
|
||||
datasets: [{
|
||||
label: '延迟 (ms)',
|
||||
data: values,
|
||||
backgroundColor: bgColors,
|
||||
borderRadius: 6,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: '#8b8d9a' }, grid: { display: false } },
|
||||
y: { ticks: { color: '#8b8d9a' }, grid: { color: '#2a2d3e' }, beginAtZero: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) { console.error('latency error:', e); }
|
||||
}
|
||||
|
||||
// ── 地理分布图 ──────────────────────────────────────────────
|
||||
async function refreshGeo() {
|
||||
try {
|
||||
const data = await fetchJSON(API + '/geo?top=10');
|
||||
const labels = data.map(d => d.region || '未知');
|
||||
const values = data.map(d => d.count);
|
||||
|
||||
createOrUpdateChart('geoChart', {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: '请求数',
|
||||
data: values,
|
||||
backgroundColor: [
|
||||
'#6366f1', '#22d3ee', '#10b981', '#f59e0b', '#ef4444',
|
||||
'#8b5cf6', '#ec4899', '#14b8a6', '#f97316', '#64748b'
|
||||
],
|
||||
borderRadius: 6,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: '#8b8d9a' }, grid: { color: '#2a2d3e' }, beginAtZero: true },
|
||||
y: { ticks: { color: '#8b8d9a' }, grid: { display: false } }
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) { console.error('geo error:', e); }
|
||||
}
|
||||
|
||||
// ── Top 端点表格 ────────────────────────────────────────────
|
||||
async function refreshTopEndpoints() {
|
||||
try {
|
||||
const data = await fetchJSON(API + '/endpoints?top=10');
|
||||
if (!data || data.length === 0) {
|
||||
document.getElementById('topEndpoints').innerHTML = '<p style="color:#8b8d9a;text-align:center;padding:20px;">暂无数据</p>';
|
||||
return;
|
||||
}
|
||||
const maxCount = data[0].count || 1;
|
||||
let html = '<table><thead><tr><th>端点</th><th>请求数</th><th>占比</th><th>平均延迟</th><th>错误率</th></tr></thead><tbody>';
|
||||
const total = data.reduce((s, d) => s + d.count, 0);
|
||||
data.forEach(d => {
|
||||
const pct = total > 0 ? (d.count / total * 100).toFixed(1) : '0';
|
||||
html += `<tr>
|
||||
<td title="${d.path}">${d.path.length > 40 ? d.path.slice(0, 37) + '...' : d.path}</td>
|
||||
<td>${d.count}</td>
|
||||
<td><div class="bar-wrap"><div class="bar-fill" style="width:${d.count/maxCount*100}px;"></div>${pct}%</div></td>
|
||||
<td>${d.avg_latency_ms}ms</td>
|
||||
<td style="color:${d.error_rate > 5 ? '#ef4444' : d.error_rate > 1 ? '#f59e0b' : '#10b981'}">${d.error_rate}%</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('topEndpoints').innerHTML = html;
|
||||
} catch (e) { console.error('endpoints error:', e); }
|
||||
}
|
||||
|
||||
// ── Top IPs ─────────────────────────────────────────────────
|
||||
async function refreshTopIPs() {
|
||||
try {
|
||||
const data = await fetchJSON(API + '/top-ips?top=15');
|
||||
if (!data || data.length === 0) {
|
||||
document.getElementById('topIPs').innerHTML = '<p style="color:#8b8d9a;text-align:center;padding:20px;">暂无数据</p>';
|
||||
return;
|
||||
}
|
||||
const maxCount = data[0].count || 1;
|
||||
let html = '<table><thead><tr><th>IP</th><th>地区</th><th>请求数</th><th>占比</th></tr></thead><tbody>';
|
||||
const total = data.reduce((s, d) => s + d.count, 0);
|
||||
data.forEach(d => {
|
||||
const pct = total > 0 ? (d.count / total * 100).toFixed(1) : '0';
|
||||
html += `<tr>
|
||||
<td>${d.ip}</td>
|
||||
<td>${d.region || '未知'}</td>
|
||||
<td>${d.count}</td>
|
||||
<td><div class="bar-wrap"><div class="bar-fill" style="width:${d.count/maxCount*80}px;"></div>${pct}%</div></td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('topIPs').innerHTML = html;
|
||||
} catch (e) { console.error('top-ips error:', e); }
|
||||
}
|
||||
|
||||
// ── 全部刷新 ────────────────────────────────────────────────
|
||||
async function refreshAll() {
|
||||
await Promise.all([
|
||||
refreshOverview(),
|
||||
refreshTimeSeries(),
|
||||
refreshStatus(),
|
||||
refreshConcurrency(),
|
||||
refreshLatency(),
|
||||
refreshGeo(),
|
||||
refreshTopEndpoints(),
|
||||
refreshTopIPs(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 启动 ────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
refreshAll();
|
||||
refreshTimer = setInterval(refreshAll, 10000); // 每10秒刷新
|
||||
});
|
||||
139
web/static/index.html
Normal file
139
web/static/index.html
Normal file
@@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>api-server - 运维监控面板</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<header>
|
||||
<h1>📊 api-server 运维监控</h1>
|
||||
<div class="header-right">
|
||||
<span class="status-dot online" id="statusDot"></span>
|
||||
<span id="statusText">运行中</span>
|
||||
<span class="uptime" id="uptime"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 实时概览卡片 -->
|
||||
<section class="overview-cards" id="overviewCards">
|
||||
<div class="card">
|
||||
<div class="card-icon">📨</div>
|
||||
<div class="card-data">
|
||||
<div class="card-value" id="totalRequests">--</div>
|
||||
<div class="card-label">总请求数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-icon">⚡</div>
|
||||
<div class="card-data">
|
||||
<div class="card-value" id="qps">--</div>
|
||||
<div class="card-label">QPS</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-icon">🔗</div>
|
||||
<div class="card-data">
|
||||
<div class="card-value" id="activeConn">--</div>
|
||||
<div class="card-label">活跃连接</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-icon">⏱️</div>
|
||||
<div class="card-data">
|
||||
<div class="card-value" id="avgLatency">--</div>
|
||||
<div class="card-label">平均延迟 ms</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-icon">❌</div>
|
||||
<div class="card-data">
|
||||
<div class="card-value" id="errorRate">--</div>
|
||||
<div class="card-label">错误率 %</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-icon">🌐</div>
|
||||
<div class="card-data">
|
||||
<div class="card-value" id="uniqueIPs">--</div>
|
||||
<div class="card-label">独立 IP</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 图表区 -->
|
||||
<div class="charts-grid">
|
||||
<!-- QPS 时间序列 -->
|
||||
<div class="chart-panel">
|
||||
<div class="chart-header">
|
||||
<h3>📈 请求量趋势</h3>
|
||||
<select id="periodSelect" onchange="refreshTimeSeries()">
|
||||
<option value="1h">最近1小时</option>
|
||||
<option value="6h">最近6小时</option>
|
||||
<option value="24h">最近24小时</option>
|
||||
</select>
|
||||
</div>
|
||||
<canvas id="qpsChart"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 状态码分布 -->
|
||||
<div class="chart-panel">
|
||||
<div class="chart-header">
|
||||
<h3>📊 状态码分布</h3>
|
||||
</div>
|
||||
<canvas id="statusChart"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 并发连接 -->
|
||||
<div class="chart-panel">
|
||||
<div class="chart-header">
|
||||
<h3>🔗 并发连接趋势</h3>
|
||||
</div>
|
||||
<canvas id="connChart"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 延迟分布 -->
|
||||
<div class="chart-panel">
|
||||
<div class="chart-header">
|
||||
<h3>⏱️ 延迟百分位</h3>
|
||||
</div>
|
||||
<canvas id="latencyChart"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 地理分布 -->
|
||||
<div class="chart-panel">
|
||||
<div class="chart-header">
|
||||
<h3>🌍 请求来源分布</h3>
|
||||
</div>
|
||||
<canvas id="geoChart"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Top 端点 -->
|
||||
<div class="chart-panel">
|
||||
<div class="chart-header">
|
||||
<h3>🔝 热门端点</h3>
|
||||
</div>
|
||||
<div class="table-wrap" id="topEndpoints"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 高频 IP -->
|
||||
<section class="section">
|
||||
<h3>👥 高频访问 IP</h3>
|
||||
<div class="table-wrap" id="topIPs"></div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<span>api-server v1.0.0</span>
|
||||
<span>Gin + Cobra + ip2region + Chart.js</span>
|
||||
<span>自动刷新 10s</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
225
web/static/style.css
Normal file
225
web/static/style.css
Normal file
@@ -0,0 +1,225 @@
|
||||
/* ── 全局 ──────────────────────────────────────────────── */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #0f1119;
|
||||
--card-bg: #1a1d2e;
|
||||
--border: #2a2d3e;
|
||||
--text: #e0e0e0;
|
||||
--text-dim: #8b8d9a;
|
||||
--accent: #6366f1;
|
||||
--accent2: #22d3ee;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* ── Header ────────────────────────────────────────────── */
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.status-dot.online { background: var(--success); }
|
||||
.status-dot.offline { background: var(--danger); }
|
||||
|
||||
.uptime {
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
/* ── 概览卡片 ──────────────────────────────────────────── */
|
||||
.overview-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 2rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.card-data {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── 图表网格 ──────────────────────────────────────────── */
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-panel {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.chart-header h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chart-header select {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 100% !important;
|
||||
max-height: 280px;
|
||||
}
|
||||
|
||||
/* ── 表格 ──────────────────────────────────────────────── */
|
||||
.table-wrap {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.table-wrap table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.table-wrap th {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.table-wrap td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table-wrap tr:hover td {
|
||||
background: rgba(99, 102, 241, 0.05);
|
||||
}
|
||||
|
||||
.bar-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.bar-fill {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--accent);
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
/* ── Section ───────────────────────────────────────────── */
|
||||
.section {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ── Footer ────────────────────────────────────────────── */
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.8rem;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
318
web/static/videoapp/app.js
Normal file
318
web/static/videoapp/app.js
Normal file
@@ -0,0 +1,318 @@
|
||||
// ── API 基础 ─────────────────────────────────────────────────
|
||||
const API = '/api';
|
||||
|
||||
async function apiGet(path) {
|
||||
const res = await fetch(API + path);
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(json.msg);
|
||||
return json.data;
|
||||
}
|
||||
|
||||
async function apiPost(path, body) {
|
||||
const res = await fetch(API + path, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(json.msg);
|
||||
return json;
|
||||
}
|
||||
|
||||
// ── 路由 ────────────────────────────────────────────────────
|
||||
let currentCategory = 'rf';
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
let currentViewkey = null;
|
||||
let extractedVideoURL = null;
|
||||
|
||||
function navigate(hash) {
|
||||
window.location.hash = hash;
|
||||
}
|
||||
|
||||
function handleRoute() {
|
||||
const hash = window.location.hash || '#/home';
|
||||
const pages = document.querySelectorAll('.page');
|
||||
pages.forEach(p => p.classList.add('hidden'));
|
||||
|
||||
// 更新导航高亮
|
||||
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
|
||||
|
||||
if (hash.startsWith('#/browse')) {
|
||||
showPage('page-browse');
|
||||
document.querySelector('.nav-link[href$="#/browse"]')?.classList.add('active');
|
||||
loadCategories();
|
||||
} else if (hash.startsWith('#/categories')) {
|
||||
showPage('page-categories');
|
||||
document.querySelector('.nav-link[href$="#/categories"]')?.classList.add('active');
|
||||
loadCategoryList();
|
||||
} else if (hash.startsWith('#/search')) {
|
||||
showPage('page-search');
|
||||
document.querySelector('.nav-link[href$="#/search"]')?.classList.add('active');
|
||||
} else if (hash.startsWith('#/watch/')) {
|
||||
const viewkey = hash.split('#/watch/')[1];
|
||||
showPage('page-watch');
|
||||
loadVideoDetail(viewkey);
|
||||
} else if (hash.startsWith('#/author/')) {
|
||||
const uid = hash.split('#/author/')[1];
|
||||
showPage('page-author');
|
||||
loadAuthorVideos(uid);
|
||||
} else {
|
||||
showPage('page-home');
|
||||
loadHomeData();
|
||||
}
|
||||
}
|
||||
|
||||
function showPage(id) {
|
||||
document.getElementById(id).classList.remove('hidden');
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
// ── 导航栏实时统计 ──────────────────────────────────────────
|
||||
async function refreshNavStats() {
|
||||
try {
|
||||
const data = await apiGet('/stats/realtime');
|
||||
document.getElementById('navQPS').textContent = data.qps;
|
||||
document.getElementById('navConn').textContent = data.active_conn;
|
||||
document.getElementById('navLat').textContent = data.avg_latency_ms;
|
||||
} catch(e) { /* 静默 */ }
|
||||
}
|
||||
|
||||
// ── 首页数据 ────────────────────────────────────────────────
|
||||
async function loadHomeData() {
|
||||
// 加载分类
|
||||
try {
|
||||
const cats = await apiGet('/categories');
|
||||
const html = cats.map(c => `
|
||||
<div class="cat-card" onclick="navigate('#/browse'); setTimeout(()=>{document.getElementById('categorySelect').value='${c.code}';loadCategoryPage();},100)">
|
||||
<h4>${c.name}</h4>
|
||||
<p>${c.desc}</p>
|
||||
</div>
|
||||
`).join('');
|
||||
document.getElementById('homeCategories').innerHTML = html;
|
||||
} catch(e) {}
|
||||
|
||||
// 加载实时统计
|
||||
try {
|
||||
const stats = await apiGet('/stats/overview');
|
||||
const items = [
|
||||
{lbl:'总请求数', val:formatNum(stats.total_requests)},
|
||||
{lbl:'QPS', val:stats.requests_per_sec},
|
||||
{lbl:'活跃连接', val:stats.active_conn},
|
||||
{lbl:'平均延迟', val:stats.avg_latency_ms+'ms'},
|
||||
{lbl:'错误率', val:stats.error_rate+'%'},
|
||||
{lbl:'独立 IP', val:formatNum(stats.unique_ips)},
|
||||
];
|
||||
document.getElementById('homeStats').innerHTML = items.map(i =>
|
||||
`<div class="stat-item"><div class="stat-val">${i.val}</div><div class="stat-lbl">${i.lbl}</div></div>`
|
||||
).join('');
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── 分类列表页 ──────────────────────────────────────────────
|
||||
async function loadCategoryList() {
|
||||
try {
|
||||
const cats = await apiGet('/categories');
|
||||
const html = cats.map(c => `
|
||||
<div class="cat-card" onclick="navigate('#/browse'); setTimeout(()=>{document.getElementById('categorySelect').value='${c.code}';loadCategoryPage();},100)">
|
||||
<h4>${c.name}</h4>
|
||||
<p>${c.desc}</p>
|
||||
<p style="margin-top:4px;color:var(--accent2);font-size:0.8rem;">代码: ${c.code}</p>
|
||||
</div>
|
||||
`).join('');
|
||||
document.getElementById('categoryList').innerHTML = `<div class="category-grid">${html}</div>`;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── 分类浏览 ────────────────────────────────────────────────
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const cats = await apiGet('/categories');
|
||||
const sel = document.getElementById('categorySelect');
|
||||
sel.innerHTML = cats.map(c => `<option value="${c.code}">${c.name}</option>`).join('');
|
||||
sel.value = currentCategory;
|
||||
loadCategoryPage();
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function loadCategoryPage() {
|
||||
currentCategory = document.getElementById('categorySelect').value;
|
||||
document.getElementById('browseTitle').textContent = getCategoryName(currentCategory);
|
||||
document.getElementById('browseLoading').style.display = 'block';
|
||||
|
||||
try {
|
||||
const data = await apiGet(`/category/${currentCategory}?page=${currentPage}`);
|
||||
totalPages = data.total_pages || 1;
|
||||
document.getElementById('pageInfo').textContent = `第 ${currentPage} 页 / 共 ${totalPages} 页`;
|
||||
document.getElementById('btnPrev').disabled = currentPage <= 1;
|
||||
document.getElementById('btnNext').disabled = currentPage >= totalPages;
|
||||
|
||||
renderVideoGrid('videoGrid', data.items || [], true);
|
||||
} catch(e) {
|
||||
document.getElementById('videoGrid').innerHTML = `<p style="color:var(--accent);text-align:center;padding:40px;">加载失败: ${e.message}</p>`;
|
||||
}
|
||||
document.getElementById('browseLoading').style.display = 'none';
|
||||
}
|
||||
|
||||
function prevPage() { if (currentPage > 1) { currentPage--; loadCategoryPage(); } }
|
||||
function nextPage() { if (currentPage < totalPages) { currentPage++; loadCategoryPage(); } }
|
||||
|
||||
// ── 搜索 ────────────────────────────────────────────────────
|
||||
async function doSearch() {
|
||||
const q = document.getElementById('searchInput').value.trim();
|
||||
if (!q) return;
|
||||
document.getElementById('searchLoading').style.display = 'block';
|
||||
document.getElementById('searchResults').innerHTML = '';
|
||||
|
||||
try {
|
||||
// 搜索:加载 rf 分类全部视频,客户端过滤标题
|
||||
const data = await apiGet('/category/rf?all=true&max_pages=3');
|
||||
const items = (data.items || []).filter(v =>
|
||||
(v.title || '').toLowerCase().includes(q.toLowerCase())
|
||||
);
|
||||
renderVideoGrid('searchResults', items, true);
|
||||
} catch(e) {
|
||||
document.getElementById('searchResults').innerHTML = `<p style="color:var(--accent);text-align:center;padding:40px;">搜索失败: ${e.message}</p>`;
|
||||
}
|
||||
document.getElementById('searchLoading').style.display = 'none';
|
||||
}
|
||||
|
||||
// ── 视频详情 + 提取 ─────────────────────────────────────────
|
||||
async function loadVideoDetail(viewkey) {
|
||||
currentViewkey = viewkey;
|
||||
const url = `https://h1014.sol148.com/view_video.php?viewkey=${viewkey}`;
|
||||
document.getElementById('videoTitle').textContent = '加载中...';
|
||||
document.getElementById('playerHint').textContent = ''+ viewkey;
|
||||
document.getElementById('videoMeta').innerHTML = '';
|
||||
document.getElementById('btnCopyURL').disabled = true;
|
||||
document.getElementById('videoSourceBox').classList.add('hidden');
|
||||
document.getElementById('playerStatus').classList.add('hidden');
|
||||
extractedVideoURL = null;
|
||||
|
||||
// 自动提取
|
||||
try {
|
||||
const result = await apiPost('/extract', {url});
|
||||
const d = result.data;
|
||||
document.getElementById('videoTitle').textContent = d.title || '无标题';
|
||||
document.getElementById('videoMeta').innerHTML = `
|
||||
<span>🕐 ${d.duration || '--'}</span>
|
||||
<span>👁 ${formatNum(d.views)}</span>
|
||||
<span>⭐ ${formatNum(d.favorites)}</span>
|
||||
${d.author_name ? `<span>👤 <a href="javascript:navigate('#/author/${d.author_url ? d.author_url.match(/UID=([^&]+)/)?.[1] || '' : ''}')" style="color:var(--accent2)">${d.author_name}</a></span>` : ''}
|
||||
`;
|
||||
|
||||
if (d.video_url) {
|
||||
extractedVideoURL = d.video_url;
|
||||
document.getElementById('playerStatus').className = 'player-status success';
|
||||
document.getElementById('playerStatus').textContent = '✅ 视频源提取成功';
|
||||
document.getElementById('playerStatus').classList.remove('hidden');
|
||||
document.getElementById('btnCopyURL').disabled = false;
|
||||
document.getElementById('videoSourceBox').classList.remove('hidden');
|
||||
document.getElementById('videoSourceURL').value = d.video_url;
|
||||
document.getElementById('playerHint').textContent = '✅ 源已提取,点击按钮复制地址';
|
||||
}
|
||||
} catch(e) {
|
||||
document.getElementById('videoTitle').textContent = '提取失败';
|
||||
document.getElementById('playerStatus').className = 'player-status error';
|
||||
document.getElementById('playerStatus').textContent = '❌ ' + e.message;
|
||||
document.getElementById('playerStatus').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 加载相关视频(同分类)
|
||||
try {
|
||||
const related = await apiGet('/category/rf?page=1');
|
||||
const relatedItems = (related.items || []).filter(v => v.viewkey !== viewkey).slice(0, 6);
|
||||
if (relatedItems.length > 0) {
|
||||
document.getElementById('videoRelated').innerHTML = '<h2>📺 更多视频</h2>' + renderVideoGridHTML(relatedItems, false);
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function extractCurrent() {
|
||||
if (!currentViewkey) return;
|
||||
document.getElementById('playerStatus').classList.remove('hidden');
|
||||
document.getElementById('playerStatus').className = 'player-status';
|
||||
document.getElementById('playerStatus').textContent = '⏳ 提取中...';
|
||||
await loadVideoDetail(currentViewkey);
|
||||
}
|
||||
|
||||
function copyVideoURL() {
|
||||
if (extractedVideoURL) {
|
||||
navigator.clipboard.writeText(extractedVideoURL).then(() => {
|
||||
const el = document.getElementById('btnCopyURL');
|
||||
const orig = el.textContent;
|
||||
el.textContent = '✅ 已复制!';
|
||||
setTimeout(() => el.textContent = orig, 1500);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 作者页 ──────────────────────────────────────────────────
|
||||
async function loadAuthorVideos(uid) {
|
||||
document.getElementById('authorName').textContent = '作者: ' + uid.substring(0, 20) + '...';
|
||||
document.getElementById('authorLoading').style.display = 'block';
|
||||
try {
|
||||
const data = await apiGet(`/author/${uid}?all=true`);
|
||||
document.getElementById('authorName').textContent = '👤 ' + (data.author_name || uid);
|
||||
renderVideoGrid('authorVideos', data.items || [], true);
|
||||
} catch(e) {
|
||||
document.getElementById('authorVideos').innerHTML = `<p style="color:var(--accent);text-align:center;padding:40px;">加载失败: ${e.message}</p>`;
|
||||
}
|
||||
document.getElementById('authorLoading').style.display = 'none';
|
||||
}
|
||||
|
||||
// ── 视频网格渲染 ────────────────────────────────────────────
|
||||
function renderVideoGrid(containerId, items, showAuthor) {
|
||||
document.getElementById(containerId).innerHTML = renderVideoGridHTML(items, showAuthor);
|
||||
}
|
||||
|
||||
function renderVideoGridHTML(items, showAuthor) {
|
||||
if (!items || items.length === 0) return '<p style="text-align:center;color:var(--text2);padding:40px;">暂无视频</p>';
|
||||
return items.map(v => {
|
||||
const viewkey = v.viewkey || '';
|
||||
const thumb = v.thumbnail || '';
|
||||
return `
|
||||
<div class="video-card" onclick="navigate('#/watch/${viewkey}')">
|
||||
<div class="video-thumb" style="${thumb ? `background-image:url(${thumb});background-size:cover;background-position:center;` : ''}">
|
||||
${thumb ? '' : '🎬'}
|
||||
</div>
|
||||
<div class="video-info">
|
||||
<h4>${escHtml(v.title || '无标题')}</h4>
|
||||
<div class="video-meta-row">
|
||||
<span>🕐 ${v.duration || '--'}</span>
|
||||
<span>👁 ${formatNum(v.views || 0)}</span>
|
||||
</div>
|
||||
<div class="video-meta-row">
|
||||
${v.added ? `<span>📅 ${v.added}</span>` : '<span></span>'}
|
||||
${showAuthor && v.author ? `<span>👤 ${escHtml(v.author)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── 工具函数 ────────────────────────────────────────────────
|
||||
function formatNum(n) {
|
||||
if (n >= 1e6) return (n/1e6).toFixed(1)+'M';
|
||||
if (n >= 1e3) return (n/1e3).toFixed(1)+'K';
|
||||
return ''+(n||0);
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function getCategoryName(code) {
|
||||
const map = {rf:'最近更新',top:'本月最热',ori:'原创',hot:'收藏最多',long:'10分钟以上',longer:'20分钟以上',tf:'最近讨论',hd:'高清',md:'每月讨论',mf:'收藏最多(月)'};
|
||||
return map[code] || code;
|
||||
}
|
||||
|
||||
// ── 事件 ────────────────────────────────────────────────────
|
||||
window.addEventListener('hashchange', handleRoute);
|
||||
window.addEventListener('load', () => {
|
||||
handleRoute();
|
||||
refreshNavStats();
|
||||
setInterval(refreshNavStats, 10000);
|
||||
});
|
||||
124
web/static/videoapp/index.html
Normal file
124
web/static/videoapp/index.html
Normal file
@@ -0,0 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sol148 Video - 视频浏览</title>
|
||||
<link rel="stylesheet" href="/videoapp/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- 顶部导航 -->
|
||||
<nav class="navbar">
|
||||
<div class="nav-inner">
|
||||
<a href="/video" class="logo">🎬 Sol148 Video</a>
|
||||
<div class="nav-links">
|
||||
<a href="/video#/browse" class="nav-link">浏览</a>
|
||||
<a href="/video#/categories" class="nav-link">分类</a>
|
||||
<a href="/video#/search" class="nav-link">搜索</a>
|
||||
</div>
|
||||
<div class="nav-stats" id="navStats">
|
||||
<span title="QPS">⚡ <b id="navQPS">--</b></span>
|
||||
<span title="活跃连接">🔗 <b id="navConn">--</b></span>
|
||||
<span title="平均延迟">⏱️ <b id="navLat">--</b>ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 主内容区(SPA 路由切换) -->
|
||||
<main id="app">
|
||||
<!-- 首页 -->
|
||||
<section id="page-home" class="page">
|
||||
<div class="hero">
|
||||
<h1>探索精彩视频内容</h1>
|
||||
<p>通过 sol148-extractor API 驱动的视频浏览平台</p>
|
||||
<div class="hero-actions">
|
||||
<button class="btn btn-primary" onclick="navigate('#/browse')">开始浏览</button>
|
||||
<button class="btn btn-outline" onclick="navigate('#/categories')">查看分类</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>🔥 热门分类</h2>
|
||||
<div class="category-grid" id="homeCategories"></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>⚡ 实时服务状态</h2>
|
||||
<div class="stats-bar" id="homeStats"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 分类浏览页 -->
|
||||
<section id="page-browse" class="page hidden">
|
||||
<div class="browse-header">
|
||||
<h2 id="browseTitle">最近更新</h2>
|
||||
<div class="browse-controls">
|
||||
<select id="categorySelect" onchange="loadCategoryPage()"></select>
|
||||
<button class="btn btn-sm" onclick="prevPage()" id="btnPrev">◀ 上一页</button>
|
||||
<span id="pageInfo">第 1 页</span>
|
||||
<button class="btn btn-sm" onclick="nextPage()" id="btnNext">下一页 ▶</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-grid" id="videoGrid"></div>
|
||||
<div class="loading" id="browseLoading" style="display:none;">加载中...</div>
|
||||
</section>
|
||||
|
||||
<!-- 分类列表页 -->
|
||||
<section id="page-categories" class="page hidden">
|
||||
<h2>📂 视频分类</h2>
|
||||
<div class="category-list" id="categoryList"></div>
|
||||
</section>
|
||||
|
||||
<!-- 搜索页 -->
|
||||
<section id="page-search" class="page hidden">
|
||||
<h2>🔍 搜索视频</h2>
|
||||
<div class="search-bar">
|
||||
<input type="text" id="searchInput" placeholder="输入关键词搜索(暂搜索标题)..." onkeydown="if(event.key==='Enter')doSearch()">
|
||||
<button class="btn btn-primary" onclick="doSearch()">搜索</button>
|
||||
</div>
|
||||
<div class="video-grid" id="searchResults"></div>
|
||||
<div class="loading" id="searchLoading" style="display:none;">搜索中...</div>
|
||||
</section>
|
||||
|
||||
<!-- 视频详情页 -->
|
||||
<section id="page-watch" class="page hidden">
|
||||
<div class="watch-container">
|
||||
<div class="video-player">
|
||||
<div class="player-placeholder" id="playerBox">
|
||||
<div class="play-icon">▶</div>
|
||||
<p>点击提取视频源</p>
|
||||
<p class="player-hint" id="playerHint"></p>
|
||||
</div>
|
||||
<div class="player-status hidden" id="playerStatus"></div>
|
||||
</div>
|
||||
<div class="video-detail">
|
||||
<h2 id="videoTitle">--</h2>
|
||||
<div class="video-meta" id="videoMeta"></div>
|
||||
<div class="video-actions">
|
||||
<button class="btn btn-primary" id="btnExtract" onclick="extractCurrent()">🔗 提取视频源</button>
|
||||
<button class="btn btn-outline" id="btnCopyURL" onclick="copyVideoURL()" disabled>📋 复制源地址</button>
|
||||
</div>
|
||||
<div class="video-source hidden" id="videoSourceBox">
|
||||
<label>视频源地址:</label>
|
||||
<input type="text" id="videoSourceURL" readonly onclick="this.select()">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="videoRelated"></div>
|
||||
</section>
|
||||
|
||||
<!-- 作者页 -->
|
||||
<section id="page-author" class="page hidden">
|
||||
<h2 id="authorName">作者</h2>
|
||||
<div class="video-grid" id="authorVideos"></div>
|
||||
<div class="loading" id="authorLoading" style="display:none;">加载中...</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span>Sol148 Video Explorer</span>
|
||||
<span>Powered by api-server (Gin + Cobra + ip2region)</span>
|
||||
<span>Data via sol148-extractor API</span>
|
||||
</footer>
|
||||
|
||||
<script src="/videoapp/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
159
web/static/videoapp/style.css
Normal file
159
web/static/videoapp/style.css
Normal file
@@ -0,0 +1,159 @@
|
||||
/* ── 全局 ──────────────────────────────────────────────── */
|
||||
:root {
|
||||
--bg: #0b0e14;
|
||||
--bg2: #141820;
|
||||
--card: #1a1e28;
|
||||
--border: #2a3040;
|
||||
--text: #e8eaed;
|
||||
--text2: #8b92a0;
|
||||
--accent: #e04060;
|
||||
--accent2: #4a90d9;
|
||||
--success: #2ecc71;
|
||||
--warning: #e67e22;
|
||||
--radius: 10px;
|
||||
}
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg); color: var(--text); min-height:100vh;
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
.loading { text-align:center; padding:40px; color:var(--text2); font-size:1.1rem; }
|
||||
.section { max-width:1200px; margin:0 auto; padding:30px 20px; }
|
||||
.section h2 { margin-bottom:20px; font-size:1.3rem; }
|
||||
|
||||
/* ── 导航栏 ────────────────────────────────────────────── */
|
||||
.navbar {
|
||||
background: var(--bg2); border-bottom:1px solid var(--border);
|
||||
position:sticky; top:0; z-index:100;
|
||||
}
|
||||
.nav-inner {
|
||||
max-width:1200px; margin:0 auto; padding:0 20px;
|
||||
display:flex; align-items:center; height:56px; gap:20px;
|
||||
}
|
||||
.logo { font-size:1.2rem; font-weight:700; color:var(--accent); text-decoration:none; }
|
||||
.nav-links { display:flex; gap:4px; flex:1; }
|
||||
.nav-link {
|
||||
color:var(--text2); text-decoration:none; padding:6px 14px;
|
||||
border-radius:6px; font-size:0.9rem; transition:.2s;
|
||||
}
|
||||
.nav-link:hover, .nav-link.active { color:var(--text); background:var(--card); }
|
||||
.nav-stats { display:flex; gap:14px; font-size:0.8rem; color:var(--text2); }
|
||||
.nav-stats b { color:var(--accent2); font-weight:600; }
|
||||
|
||||
/* ── 首页 Hero ─────────────────────────────────────────── */
|
||||
.hero {
|
||||
text-align:center; padding:80px 20px 50px;
|
||||
background: linear-gradient(135deg, var(--bg2), var(--bg));
|
||||
}
|
||||
.hero h1 { font-size:2.4rem; margin-bottom:12px; }
|
||||
.hero p { color:var(--text2); font-size:1.1rem; margin-bottom:30px; }
|
||||
.hero-actions { display:flex; gap:12px; justify-content:center; }
|
||||
|
||||
/* ── 按钮 ──────────────────────────────────────────────── */
|
||||
.btn {
|
||||
padding:10px 24px; border:none; border-radius:8px; cursor:pointer;
|
||||
font-size:0.95rem; font-weight:600; transition:.2s;
|
||||
}
|
||||
.btn-primary { background:var(--accent); color:#fff; }
|
||||
.btn-primary:hover { opacity:.85; }
|
||||
.btn-outline { background:transparent; color:var(--accent2); border:1px solid var(--accent2); }
|
||||
.btn-outline:hover { background:var(--accent2); color:#fff; }
|
||||
.btn-sm { padding:6px 14px; font-size:0.85rem; }
|
||||
.btn:disabled { opacity:.4; cursor:not-allowed; }
|
||||
|
||||
/* ── 分类网格 ──────────────────────────────────────────── */
|
||||
.category-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:12px; }
|
||||
.cat-card {
|
||||
background:var(--card); border:1px solid var(--border);
|
||||
border-radius:var(--radius); padding:20px; cursor:pointer; transition:.2s;
|
||||
}
|
||||
.cat-card:hover { border-color:var(--accent); }
|
||||
.cat-card h4 { font-size:1.05rem; margin-bottom:4px; }
|
||||
.cat-card p { font-size:0.8rem; color:var(--text2); }
|
||||
|
||||
/* ── 视频网格 ──────────────────────────────────────────── */
|
||||
.video-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:16px; max-width:1200px; margin:0 auto; padding:0 20px; }
|
||||
.video-card {
|
||||
background:var(--card); border:1px solid var(--border); border-radius:var(--radius);
|
||||
overflow:hidden; cursor:pointer; transition:.2s;
|
||||
}
|
||||
.video-card:hover { border-color:var(--accent); transform:translateY(-2px); }
|
||||
.video-thumb {
|
||||
width:100%; aspect-ratio:16/9; object-fit:cover; background:var(--bg2);
|
||||
display:flex; align-items:center; justify-content:center; color:var(--text2); font-size:3rem;
|
||||
}
|
||||
.video-info { padding:12px 14px; }
|
||||
.video-info h4 { font-size:0.95rem; margin-bottom:4px; line-height:1.4; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
|
||||
.video-meta-row { display:flex; justify-content:space-between; font-size:0.78rem; color:var(--text2); margin-top:6px; }
|
||||
.video-meta-row span { display:flex; align-items:center; gap:3px; }
|
||||
.duration-badge { background:rgba(0,0,0,.7); color:#fff; padding:2px 6px; border-radius:4px; font-size:0.75rem; }
|
||||
|
||||
/* ── 浏览页头 ──────────────────────────────────────────── */
|
||||
.browse-header {
|
||||
max-width:1200px; margin:0 auto; padding:20px 20px 10px;
|
||||
display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:12px;
|
||||
}
|
||||
.browse-controls { display:flex; align-items:center; gap:12px; }
|
||||
#categorySelect {
|
||||
background:var(--bg2); color:var(--text); border:1px solid var(--border);
|
||||
border-radius:6px; padding:6px 10px; font-size:0.9rem;
|
||||
}
|
||||
#pageInfo { font-size:0.85rem; color:var(--text2); min-width:60px; text-align:center; }
|
||||
|
||||
/* ── 搜索栏 ────────────────────────────────────────────── */
|
||||
.search-bar { display:flex; gap:8px; max-width:600px; margin:20px auto; padding:0 20px; }
|
||||
.search-bar input {
|
||||
flex:1; padding:10px 16px; background:var(--bg2); border:1px solid var(--border);
|
||||
border-radius:8px; color:var(--text); font-size:1rem;
|
||||
}
|
||||
.search-bar input:focus { outline:none; border-color:var(--accent); }
|
||||
|
||||
/* ── 视频播放页 ────────────────────────────────────────── */
|
||||
.watch-container { max-width:1000px; margin:0 auto; padding:20px; display:grid; grid-template-columns:2fr 1fr; gap:24px; }
|
||||
.player-placeholder {
|
||||
width:100%; aspect-ratio:16/9; background:var(--bg2);
|
||||
display:flex; flex-direction:column; align-items:center; justify-content:center;
|
||||
border-radius:var(--radius); border:1px solid var(--border); cursor:pointer;
|
||||
}
|
||||
.play-icon { font-size:3rem; margin-bottom:8px; opacity:.6; }
|
||||
.player-hint { font-size:0.8rem; color:var(--text2); margin-top:4px; }
|
||||
.player-placeholder:hover { border-color:var(--accent); }
|
||||
.player-status { padding:12px; background:var(--card); border-radius:8px; margin-top:8px; font-size:0.85rem; }
|
||||
.player-status.success { border-left:3px solid var(--success); color:var(--success); }
|
||||
.player-status.error { border-left:3px solid var(--accent); color:var(--accent); }
|
||||
.video-detail h2 { font-size:1.3rem; margin-bottom:12px; }
|
||||
.video-meta { display:flex; flex-wrap:wrap; gap:16px; margin-bottom:16px; font-size:0.85rem; color:var(--text2); }
|
||||
.video-actions { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; }
|
||||
.video-source { margin-top:8px; }
|
||||
.video-source label { font-size:0.8rem; color:var(--text2); display:block; margin-bottom:4px; }
|
||||
.video-source input {
|
||||
width:100%; padding:8px 10px; background:var(--bg2); border:1px solid var(--border);
|
||||
border-radius:6px; color:var(--accent); font-size:0.8rem;
|
||||
}
|
||||
|
||||
/* ── 统计条 ────────────────────────────────────────────── */
|
||||
.stats-bar {
|
||||
display:flex; flex-wrap:wrap; gap:16px; justify-content:center;
|
||||
}
|
||||
.stat-item {
|
||||
background:var(--card); border:1px solid var(--border); border-radius:var(--radius);
|
||||
padding:16px 24px; text-align:center; min-width:120px;
|
||||
}
|
||||
.stat-item .stat-val { font-size:1.5rem; font-weight:700; color:var(--accent2); }
|
||||
.stat-item .stat-lbl { font-size:0.78rem; color:var(--text2); margin-top:2px; }
|
||||
|
||||
/* ── Footer ─────────────────────────────────────────────── */
|
||||
footer {
|
||||
text-align:center; padding:20px; color:var(--text2); font-size:0.78rem;
|
||||
border-top:1px solid var(--border); margin-top:40px;
|
||||
display:flex; gap:16px; justify-content:center; flex-wrap:wrap;
|
||||
}
|
||||
|
||||
/* ── 响应式 ────────────────────────────────────────────── */
|
||||
@media (max-width:768px) {
|
||||
.watch-container { grid-template-columns:1fr; }
|
||||
.hero h1 { font-size:1.6rem; }
|
||||
.video-grid { grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); }
|
||||
.nav-stats { display:none; }
|
||||
}
|
||||
Reference in New Issue
Block a user