Files
fanqie-go/internal/handlers/common.go
T
chenxin bfb4290c15 ✨ feat(status): 新增请求日志并重设计状态页
- 新增 server.request_log 配置:记录业务请求元数据(IP/方法/路径/状态/耗时,
  key 自动脱敏)与 4xx/5xx 错误摘要(前 4KB),按保留天数自动清理
- 移除 /self-info 与 /vacuum 接口及相关文档
- 状态页重设计:毛玻璃卡片+吸顶玻璃栏、服务状态条、KPI 卡与请求分类
  条形图、可折叠 API 文档分组、行内测试面板;深浅色分别调校,
  含 reduced-motion / reduced-transparency / 无 backdrop-filter 兜底
2026-10-09 18:03:02 +08:00

285 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"crypto/subtle"
"encoding/json"
"fanqie/internal/config"
"fanqie/internal/fqapi"
"fmt"
"net"
"net/http"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"golang.org/x/time/rate"
)
var (
startTime = time.Now()
deviceHeader string
// 请求统计
requestStats = newRequestStats()
// 统计保存定时器
statsSaveTicker *time.Ticker
)
// statsKinds 统计类别
var statsKinds = []string{"total", "search", "detail", "catalog", "content", "manga", "audio", "video", "category"}
// newRequestStats 初始化各类计数器
func newRequestStats() map[string]*atomic.Int64 {
m := make(map[string]*atomic.Int64, len(statsKinds))
for _, k := range statsKinds {
m[k] = &atomic.Int64{}
}
return m
}
// incrementStat 原子递增请求计数
func incrementStat(kinds ...string) {
for _, k := range kinds {
requestStats[k].Add(1)
}
}
// getStatsSnapshot 获取所有计数器的快照
func getStatsSnapshot() map[string]int64 {
snapshot := make(map[string]int64, len(requestStats))
for k, c := range requestStats {
snapshot[k] = c.Load()
}
return snapshot
}
// SetDeviceHeader 设置设备header
func SetDeviceHeader(header string) {
deviceHeader = header
}
// LoadStatistics 从数据库加载统计数据
func LoadStatistics() {
stats, err := fqapi.LoadStatistics()
if err != nil {
fmt.Printf("加载统计数据失败: %v\n", err)
return
}
for k, v := range stats {
if c, ok := requestStats[k]; ok {
c.Store(v)
}
}
}
// SaveStatistics 保存统计数据到数据库
func SaveStatistics() {
if err := fqapi.SaveStatistics(getStatsSnapshot()); err != nil {
fmt.Printf("保存统计数据失败: %v\n", err)
}
}
// StartStatisticsSaver 启动统计数据定期保存
func StartStatisticsSaver() {
// 每30秒保存一次统计数据
statsSaveTicker = time.NewTicker(30 * time.Second)
go func() {
for range statsSaveTicker.C {
SaveStatistics()
}
}()
}
// StopStatisticsSaver 停止统计数据定期保存
func StopStatisticsSaver() {
if statsSaveTicker != nil {
statsSaveTicker.Stop()
// 最后保存一次
SaveStatistics()
}
}
// respondError 统一错误响应(JSON 安全编码;isClientErr 为 true 时返回 400,否则 500)
func respondError(w http.ResponseWriter, msg string, isClientErr bool) {
status := http.StatusInternalServerError
if isClientErr {
status = http.StatusBadRequest
}
respondJSON(w, status, map[string]interface{}{"code": -1, "msg": msg})
}
// RateLimit 每 IP 限流(server.rate_limit_qps > 0 时启用)
// ponytail: 配置启动时读取一次,热更新不生效;visitor 表由后台协程清理防泄漏
func RateLimit(handler http.HandlerFunc) http.HandlerFunc {
cfg := config.GetAppConfig()
qps := 0.0
if cfg != nil {
qps = cfg.Server.RateLimitQPS
}
if qps <= 0 {
return handler
}
const burst = 10
var mu sync.Mutex
limitters := map[string]*rate.Limiter{}
lastSeen := map[string]time.Time{}
go func() {
for range time.Tick(time.Minute) {
mu.Lock()
for ip, t := range lastSeen {
if time.Since(t) > 10*time.Minute {
delete(lastSeen, ip)
delete(limitters, ip)
}
}
mu.Unlock()
}
}()
return func(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
mu.Lock()
l, ok := limitters[ip]
if !ok {
l = rate.NewLimiter(rate.Limit(qps), burst)
limitters[ip] = l
}
lastSeen[ip] = time.Now()
mu.Unlock()
if !l.Allow() {
respondJSON(w, http.StatusTooManyRequests, map[string]interface{}{
"code": -1, "msg": "请求过于频繁,请稍后再试",
})
return
}
handler(w, r)
}
}
// clientIP 取客户端 IP:优先 X-Forwarded-For 首段(反代场景),否则 RemoteAddr
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0])
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// RequireAccessKey 验证访问密钥中间件
func RequireAccessKey(handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cfg := config.GetConfig()
// 如果未配置 access_key 或为空,则不启用验证
if cfg == nil || cfg.Server.AccessKey == "" {
handler(w, r)
return
}
// 从查询参数或 Header 中获取 key
key := r.URL.Query().Get("key")
if key == "" {
key = r.Header.Get("X-Access-Key")
}
// 验证密钥(常量时间比较,防时序攻击)
if subtle.ConstantTimeCompare([]byte(key), []byte(cfg.Server.AccessKey)) != 1 {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"code": -1, "msg": "访问被拒绝:无效的访问密钥"}`)
return
}
// 验证通过,继续处理请求
handler(w, r)
}
}
// OptionalAccessKey 业务接口保护(仅 server.protect_all 开启时校验)
func OptionalAccessKey(handler http.HandlerFunc) http.HandlerFunc {
cfg := config.GetAppConfig()
if cfg == nil || !cfg.Server.ProtectAll {
return handler
}
return RequireAccessKey(handler)
}
// statusWriter 捕获状态码;status>=400 时截断留存响应体前 ErrMsgMax 字节用于排障
type statusWriter struct {
http.ResponseWriter
status int
errBuf []byte
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Write(p []byte) (int, error) {
if w.status >= 400 && len(w.errBuf) < fqapi.ErrMsgMax {
n := min(len(p), fqapi.ErrMsgMax-len(w.errBuf))
w.errBuf = append(w.errBuf, p[:n]...)
}
return w.ResponseWriter.Write(p)
}
// LogRequestMiddleware 异步记录请求元数据(时间/IP/方法/路径/状态/耗时)到数据库
// ponytail: 配置启动时读取一次,热更新不生效;未启用时零开销直通
func LogRequestMiddleware(handler http.HandlerFunc) http.HandlerFunc {
cfg := config.GetAppConfig()
if cfg == nil || !cfg.Server.RequestLog.Enabled {
return handler
}
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
handler(sw, r)
query := sanitizeQuery(r.URL.RawQuery)
fqapi.LogRequest(fqapi.RequestLogEntry{
Ts: start.Unix(),
IP: clientIP(r),
Method: r.Method,
Path: r.URL.Path,
Query: query,
Status: sw.status,
DurationMs: time.Since(start).Milliseconds(),
ErrorMsg: string(sw.errBuf),
})
}
}
// keyParamRe 匹配查询串中独立的 key 参数(accesskey 等不会被误伤)
var keyParamRe = regexp.MustCompile(`(?i)(^|&)key=[^&]*`)
// sanitizeQuery 日志中脱敏:key 参数值替换为 ***(直接替换避免 Encode 把 *** 转义)
func sanitizeQuery(rawQuery string) string {
return keyParamRe.ReplaceAllString(rawQuery, "${1}key=***")
}
// HandleHealth 存活探针(供容器/反向代理使用)
func HandleHealth(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
}
// HandleMain 主页处理
func HandleMain(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
response := map[string]interface{}{
"code": 0,
"message": "success",
"data": nil,
}
json.NewEncoder(w).Encode(response)
}