Files
fanqie-go/internal/handlers/common.go
T
chenxin 126a96d744 ✨ feat(database): 完善请求日志体系并为全表补充时间字段
- request_log 新增 user_agent 列(含旧表 ALTER 迁移)及 ip/status 索引,记录解码后的明文 query(含 key,不脱敏)
- 请求日志批量写入改为多值 INSERT 单次往返,替代事务逐条 Exec
- 新增按本地时区分天聚合查询,状态页展示今日请求量与近 7 天请求趋势(峰值日高亮)
- 新增 TZ 时区支持:.env 配置 TZ 后日志时间戳与状态页全部时间统一生效,二进制内嵌 tzdata 保证容器可用
- 五张表统一补齐 created_at/updated_at 字段,启动时自动补列并按业务时间回填旧行
- device_pool 的 device_header 增加唯一约束;audio_cache 写入改为 UPSERT 修复同 key 重复报错
- 状态页"最近请求"新增 UA 列与限流状态显示;config.yaml 暴露 rate_limit_qps 配置并默认启用请求日志
2026-10-09 22:08:04 +08:00

304 lines
7.6 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"
"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()
}
}
// getQueryWithAlias 读 URL 参数,主名未传时回退到别名(兼容 README 历史参数名)
func getQueryWithAlias(r *http.Request, name, alias string) string {
if v := r.URL.Query().Get(name); v != "" {
return v
}
return r.URL.Query().Get(alias)
}
// 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)
ua := r.UserAgent()
if len(ua) > 512 { // 防恶意超长 UA 撑爆日志
ua = ua[:512]
}
fqapi.LogRequest(fqapi.RequestLogEntry{
Ts: start.Unix(),
IP: clientIP(r),
Method: r.Method,
Path: r.URL.Path,
Query: decodedQuery(r),
Status: sw.status,
DurationMs: time.Since(start).Milliseconds(),
ErrorMsg: string(sw.errBuf),
UserAgent: ua,
})
}
}
// decodedQuery 重组解码后的查询串(keyword=%E6%B5%8B → keyword=测),日志可读
// ponytail: map 遍历乱序、%26 解码后与分隔符同名 —— 展示用途可接受
func decodedQuery(r *http.Request) string {
vals := r.URL.Query() // 已解码
if len(vals) == 0 {
return ""
}
parts := make([]string, 0, len(vals))
for k, vs := range vals {
for _, v := range vs {
parts = append(parts, k+"="+v)
}
}
return strings.Join(parts, "&")
}
// 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)
}