From b1867e13c03aab97d3b8aff85d2f980adf019bed Mon Sep 17 00:00:00 2001 From: yuqianhe Date: Wed, 10 Jun 2026 02:33:35 +0000 Subject: [PATCH] Initial commit: 91 API Server --- .gitignore | 10 + README.md | 211 +++++++++++ cmd/root.go | 75 ++++ cmd/serve.go | 57 +++ go.mod | 50 +++ go.sum | 110 ++++++ internal/analytics/engine.go | 628 +++++++++++++++++++++++++++++++ internal/analytics/geoip.go | 140 +++++++ internal/config/config.go | 87 +++++ internal/handler/content.go | 114 ++++++ internal/handler/extract.go | 116 ++++++ internal/handler/handler.go | 33 ++ internal/handler/stats.go | 144 +++++++ internal/middleware/analytics.go | 44 +++ internal/model/stats.go | 107 ++++++ internal/scraper/author.go | 182 +++++++++ internal/scraper/category.go | 244 ++++++++++++ internal/scraper/extractor.go | 107 ++++++ internal/scraper/session.go | 76 ++++ internal/server/server.go | 187 +++++++++ main.go | 7 + web/embed.go | 21 ++ web/static/app.js | 341 +++++++++++++++++ web/static/index.html | 139 +++++++ web/static/style.css | 225 +++++++++++ web/static/videoapp/app.js | 318 ++++++++++++++++ web/static/videoapp/index.html | 124 ++++++ web/static/videoapp/style.css | 159 ++++++++ 28 files changed, 4056 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cmd/root.go create mode 100644 cmd/serve.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/analytics/engine.go create mode 100644 internal/analytics/geoip.go create mode 100644 internal/config/config.go create mode 100644 internal/handler/content.go create mode 100644 internal/handler/extract.go create mode 100644 internal/handler/handler.go create mode 100644 internal/handler/stats.go create mode 100644 internal/middleware/analytics.go create mode 100644 internal/model/stats.go create mode 100644 internal/scraper/author.go create mode 100644 internal/scraper/category.go create mode 100644 internal/scraper/extractor.go create mode 100644 internal/scraper/session.go create mode 100644 internal/server/server.go create mode 100644 main.go create mode 100644 web/embed.go create mode 100644 web/static/app.js create mode 100644 web/static/index.html create mode 100644 web/static/style.css create mode 100644 web/static/videoapp/app.js create mode 100644 web/static/videoapp/index.html create mode 100644 web/static/videoapp/style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d9c269a --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Binary +api-server + +# Runtime data +data/ + +# IDE +.idea/ +.vscode/ +*.swp diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e6801e --- /dev/null +++ b/README.md @@ -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** — 资源嵌入 diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..9c4aaac --- /dev/null +++ b/cmd/root.go @@ -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()) + } +} diff --git a/cmd/serve.go b/cmd/serve.go new file mode 100644 index 0000000..1da567d --- /dev/null +++ b/cmd/serve.go @@ -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") + }, +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5055f8a --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..af8ba3c --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/analytics/engine.go b/internal/analytics/engine.go new file mode 100644 index 0000000..7468db3 --- /dev/null +++ b/internal/analytics/engine.go @@ -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) +} diff --git a/internal/analytics/geoip.go b/internal/analytics/geoip.go new file mode 100644 index 0000000..44daaa4 --- /dev/null +++ b/internal/analytics/geoip.go @@ -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) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..324773c --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/handler/content.go b/internal/handler/content.go new file mode 100644 index 0000000..a562a11 --- /dev/null +++ b/internal/handler/content.go @@ -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}) +} diff --git a/internal/handler/extract.go b/internal/handler/extract.go new file mode 100644 index 0000000..adc6b01 --- /dev/null +++ b/internal/handler/extract.go @@ -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, + }) +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go new file mode 100644 index 0000000..67c4a0f --- /dev/null +++ b/internal/handler/handler.go @@ -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") +} diff --git a/internal/handler/stats.go b/internal/handler/stats.go new file mode 100644 index 0000000..d479ec5 --- /dev/null +++ b/internal/handler/stats.go @@ -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 +} diff --git a/internal/middleware/analytics.go b/internal/middleware/analytics.go new file mode 100644 index 0000000..c67d434 --- /dev/null +++ b/internal/middleware/analytics.go @@ -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) + } +} diff --git a/internal/model/stats.go b/internal/model/stats.go new file mode 100644 index 0000000..dc4f54e --- /dev/null +++ b/internal/model/stats.go @@ -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"` +} diff --git a/internal/scraper/author.go b/internal/scraper/author.go new file mode 100644 index 0000000..cde5f50 --- /dev/null +++ b/internal/scraper/author.go @@ -0,0 +1,182 @@ +package scraper + +import ( + "fmt" + "io" + "math" + "regexp" + "strings" +) + +const PerPage = 8 + +// ── 正则 ──────────────────────────────────────────────────── + +var ( + reAuthorVideoLink = regexp.MustCompile(`]*src="([^"]+)"`) + reAuthorDuration = regexp.MustCompile(`]*>([\d:]+)`) + reAuthorTitle = regexp.MustCompile(`(?s)]*>(.*?)`) + reAuthorWellBlock = regexp.MustCompile(`(?s)(.*?)\s*`) + reAuthorViews = regexp.MustCompile(`(?i)(?:热度|Views):\s*(\d[\d,]*)`) + reAuthorFavorites = regexp.MustCompile(`(?i)(?:收藏|Favorites):\s*(\d[\d,]*)`) + reAuthorComments = regexp.MustCompile(`(?i)(?:留言|Comments):\s*(\d[\d,]*)`) + reAuthorPoint = regexp.MustCompile(`(?i)(?:积分|Point):\s*(\d[\d,]*)`) + reAuthorAdded = regexp.MustCompile(`(?i)(?:添加时间|Added):\s*([^<]+)`) + reAuthorPageInfo = regexp.MustCompile(`视频\s*\d+-\d+\s*的\s*(\d+)`) + reAuthorName = regexp.MustCompile(`(?i)]*>\s*([^<\n]+)`) + reAuthorTotalVids = regexp.MustCompile(`(?i)\s*\((\d+)[^)]*\)\s*`) +) + +// ── 数据模型 ──────────────────────────────────────────────── + +// 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 +} diff --git a/internal/scraper/category.go b/internal/scraper/category.go new file mode 100644 index 0000000..040565a --- /dev/null +++ b/internal/scraper/category.go @@ -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)(.*?)`) + reCatThumb = regexp.MustCompile(`(?i)]*src="([^"]+)"`) + reCatDuration = regexp.MustCompile(`]*>([\d:]+)`) + reCatTitle = regexp.MustCompile(`(?s)]*>(.*?)`) + reCatWellStart = regexp.MustCompile(``) + reCatAuthor = regexp.MustCompile(`(?i)作者:\s*([^<]+)`) + reCatViews = regexp.MustCompile(`(?i)热度:\s*(\d[\d,]*)`) + reCatFavorites = regexp.MustCompile(`(?i)收藏:\s*(\d[\d,]*)`) + reCatComments = regexp.MustCompile(`(?i)留言:\s*(\d[\d,]*)`) + reCatAdded = regexp.MustCompile(`(?i)添加时间:\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, `
    \s*(.+?)\s*-\s*91porn\s*`) + reDuration = regexp.MustCompile(`时长:\s*]*>([\d:]+)`) + reViews = regexp.MustCompile(`热度:\s*]*>(\d[\d,]*)`) + reFavorites = regexp.MustCompile(`收藏:\s*]*>(\d[\d,]*)`) + reAuthorLink = regexp.MustCompile(`(?i)\s*]*>\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("解码后未找到视频源 ") + } + 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 +} diff --git a/internal/scraper/session.go b/internal/scraper/session.go new file mode 100644 index 0000000..a0bfa62 --- /dev/null +++ b/internal/scraper/session.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..ce5dc2b --- /dev/null +++ b/internal/server/server.go @@ -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 +} + + diff --git a/main.go b/main.go new file mode 100644 index 0000000..9bb98f5 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "api-server/cmd" + +func main() { + cmd.Execute() +} diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..8c01256 --- /dev/null +++ b/web/embed.go @@ -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) + } +} diff --git a/web/static/app.js b/web/static/app.js new file mode 100644 index 0000000..8e82b25 --- /dev/null +++ b/web/static/app.js @@ -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 = '

    暂无数据

    '; + return; + } + const maxCount = data[0].count || 1; + let html = ''; + 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 += ` + + + + + + `; + }); + html += '
    端点请求数占比平均延迟错误率
    ${d.path.length > 40 ? d.path.slice(0, 37) + '...' : d.path}${d.count}
    ${pct}%
    ${d.avg_latency_ms}ms${d.error_rate}%
    '; + 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 = '

    暂无数据

    '; + return; + } + const maxCount = data[0].count || 1; + let html = ''; + 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 += ` + + + + + `; + }); + html += '
    IP地区请求数占比
    ${d.ip}${d.region || '未知'}${d.count}
    ${pct}%
    '; + 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秒刷新 +}); diff --git a/web/static/index.html b/web/static/index.html new file mode 100644 index 0000000..a218b6a --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,139 @@ + + + + + + api-server - 运维监控面板 + + + + +
    + +
    +

    📊 api-server 运维监控

    +
    + + 运行中 + +
    +
    + + +
    +
    +
    📨
    +
    +
    --
    +
    总请求数
    +
    +
    +
    +
    +
    +
    --
    +
    QPS
    +
    +
    +
    +
    🔗
    +
    +
    --
    +
    活跃连接
    +
    +
    +
    +
    ⏱️
    +
    +
    --
    +
    平均延迟 ms
    +
    +
    +
    +
    +
    +
    --
    +
    错误率 %
    +
    +
    +
    +
    🌐
    +
    +
    --
    +
    独立 IP
    +
    +
    +
    + + +
    + +
    +
    +

    📈 请求量趋势

    + +
    + +
    + + +
    +
    +

    📊 状态码分布

    +
    + +
    + + +
    +
    +

    🔗 并发连接趋势

    +
    + +
    + + +
    +
    +

    ⏱️ 延迟百分位

    +
    + +
    + + +
    +
    +

    🌍 请求来源分布

    +
    + +
    + + +
    +
    +

    🔝 热门端点

    +
    +
    +
    +
    + + +
    +

    👥 高频访问 IP

    +
    +
    + +
    + api-server v1.0.0 + Gin + Cobra + ip2region + Chart.js + 自动刷新 10s +
    +
    + + + + diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..38dd083 --- /dev/null +++ b/web/static/style.css @@ -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; +} diff --git a/web/static/videoapp/app.js b/web/static/videoapp/app.js new file mode 100644 index 0000000..d4b5df7 --- /dev/null +++ b/web/static/videoapp/app.js @@ -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 => ` +
    +

    ${c.name}

    +

    ${c.desc}

    +
    + `).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 => + `
    ${i.val}
    ${i.lbl}
    ` + ).join(''); + } catch(e) {} +} + +// ── 分类列表页 ────────────────────────────────────────────── +async function loadCategoryList() { + try { + const cats = await apiGet('/categories'); + const html = cats.map(c => ` +
    +

    ${c.name}

    +

    ${c.desc}

    +

    代码: ${c.code}

    +
    + `).join(''); + document.getElementById('categoryList').innerHTML = `
    ${html}
    `; + } catch(e) {} +} + +// ── 分类浏览 ──────────────────────────────────────────────── +async function loadCategories() { + try { + const cats = await apiGet('/categories'); + const sel = document.getElementById('categorySelect'); + sel.innerHTML = cats.map(c => ``).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 = `

    加载失败: ${e.message}

    `; + } + 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 = `

    搜索失败: ${e.message}

    `; + } + 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 = ` + 🕐 ${d.duration || '--'} + 👁 ${formatNum(d.views)} + ⭐ ${formatNum(d.favorites)} + ${d.author_name ? `👤 ${d.author_name}` : ''} + `; + + 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 = '

    📺 更多视频

    ' + 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 = `

    加载失败: ${e.message}

    `; + } + 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 '

    暂无视频

    '; + return items.map(v => { + const viewkey = v.viewkey || ''; + const thumb = v.thumbnail || ''; + return ` +
    +
    + ${thumb ? '' : '🎬'} +
    +
    +

    ${escHtml(v.title || '无标题')}

    +
    + 🕐 ${v.duration || '--'} + 👁 ${formatNum(v.views || 0)} +
    +
    + ${v.added ? `📅 ${v.added}` : ''} + ${showAuthor && v.author ? `👤 ${escHtml(v.author)}` : ''} +
    +
    +
    `; + }).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); +}); diff --git a/web/static/videoapp/index.html b/web/static/videoapp/index.html new file mode 100644 index 0000000..faa4a0e --- /dev/null +++ b/web/static/videoapp/index.html @@ -0,0 +1,124 @@ + + + + + + Sol148 Video - 视频浏览 + + + + + + + +
    + +
    +
    +

    探索精彩视频内容

    +

    通过 sol148-extractor API 驱动的视频浏览平台

    +
    + + +
    +
    +
    +

    🔥 热门分类

    +
    +
    +
    +

    ⚡ 实时服务状态

    +
    +
    +
    + + + + + + + + + + + + + + + +
    + +
    + Sol148 Video Explorer + Powered by api-server (Gin + Cobra + ip2region) + Data via sol148-extractor API +
    + + + + diff --git a/web/static/videoapp/style.css b/web/static/videoapp/style.css new file mode 100644 index 0000000..09316eb --- /dev/null +++ b/web/static/videoapp/style.css @@ -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; } +}