diff --git a/.gitignore b/.gitignore index 4d79f69..7a97fb0 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,4 @@ fanqie-* # 环境变量(含真实密码,勿提交) .env +.finesse/ diff --git a/README.md b/README.md index d60a6d5..edcbb5d 100644 --- a/README.md +++ b/README.md @@ -138,8 +138,6 @@ go run ./cmd/fanqie | `GET /` | GET | 服务首页 | | `GET /healthz` | GET | 存活探针(不校验密钥,供容器/反代使用) | | `GET /status` | GET | 服务状态(需 AccessKey) | -| `GET /self-info` | GET | 自我信息(需 AccessKey) | -| `POST /vacuum` | POST | 数据库压缩(需 AccessKey) | ### 书籍相关 @@ -240,6 +238,12 @@ server: access_key: "" # 访问密钥(可选) protect_all: false # 业务接口也要求访问密钥(公网部署建议开启) + # 请求日志:记录 时间/IP/方法/路径/状态/耗时(query 中的 key 自动脱敏), + # 4xx/5xx 额外保留错误响应摘要(前 4KB);不记录响应体。/status 页可查看最近 20 条 + request_log: + enabled: false # 是否启用 + retention_days: 7 # 保留天数,自动清理 + # 番茄小说配置 fanqie: # 签名算法: "gorgon" / "8404" diff --git a/cmd/fanqie/main.go b/cmd/fanqie/main.go index 74a4e72..abaf33b 100644 --- a/cmd/fanqie/main.go +++ b/cmd/fanqie/main.go @@ -56,6 +56,12 @@ func main() { fqapi.StartCacheCleanup() defer fqapi.StopCacheCleanup() // 程序退出时停止清理任务 + // 启动请求日志(server.request_log.enabled 开启时生效) + if err := fqapi.InitRequestLog(); err != nil { + logger.Warning("请求日志初始化失败: " + err.Error()) + } + defer fqapi.CloseRequestLog() // 程序退出时落盘剩余日志 + // 初始化算法切换器 initAlgorithm() @@ -139,14 +145,12 @@ func initDeviceKeyPool() error { func setupRoutes() { http.HandleFunc("/", handlers.HandleMain) http.HandleFunc("/status", handlers.RequireAccessKey(handlers.HandleStatus)) - http.HandleFunc("/self-info", handlers.RequireAccessKey(handlers.HandleSelfInfo)) - http.HandleFunc("/vacuum", handlers.RequireAccessKey(handlers.HandleVacuum)) http.HandleFunc("/healthz", handlers.HandleHealth) - // API端点:限流 + protect_all 鉴权链(代理路由不鉴权,URL 由服务端生成不含 key) + // API端点:请求日志 + 限流 + protect_all 鉴权链(代理路由不鉴权,URL 由服务端生成不含 key) wrap := func(h http.HandlerFunc) http.HandlerFunc { - return handlers.RateLimit(handlers.OptionalAccessKey(h)) + return handlers.LogRequestMiddleware(handlers.RateLimit(handlers.OptionalAccessKey(h))) } http.HandleFunc("/search", wrap(handlers.HandleSearch)) http.HandleFunc("/detail", wrap(handlers.HandleDetail)) diff --git a/config/config.yaml b/config/config.yaml index 7954a73..a92bb05 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -4,7 +4,12 @@ server: port: "40180" # 监听端口 host: "0.0.0.0" # 监听地址 - access_key: "" # 访问密钥,为空则不启用验证(用于 /status 和 /self-info) + access_key: "" # 访问密钥,为空则不启用验证(用于 /status) + + # 请求日志(只记元数据与错误摘要,不存响应体;在 /status 页"最近请求"展示) + request_log: + enabled: false + retention_days: 7 # 日志保留天数,自动清理 # 番茄小说配置 fanqie: diff --git a/internal/config/config.go b/internal/config/config.go index 1c5d3f8..c3e0909 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,11 +19,18 @@ type Config struct { // ServerConfig 服务器配置 type ServerConfig struct { - Port string `mapstructure:"port"` - Host string `mapstructure:"host"` - AccessKey string `mapstructure:"access_key"` - ProtectAll bool `mapstructure:"protect_all"` // 业务接口也要求访问密钥(公网部署建议开启) - RateLimitQPS float64 `mapstructure:"rate_limit_qps"` // 每 IP 每秒请求上限,0=不限制 + Port string `mapstructure:"port"` + Host string `mapstructure:"host"` + AccessKey string `mapstructure:"access_key"` + ProtectAll bool `mapstructure:"protect_all"` // 业务接口也要求访问密钥(公网部署建议开启) + RateLimitQPS float64 `mapstructure:"rate_limit_qps"` // 每 IP 每秒请求上限,0=不限制 + RequestLog RequestLogConfig `mapstructure:"request_log"` // 请求日志(只记元数据与错误摘要) +} + +// RequestLogConfig 请求日志配置 +type RequestLogConfig struct { + Enabled bool `mapstructure:"enabled"` // 是否记录业务接口请求元数据 + RetentionDays int `mapstructure:"retention_days"` // 日志保留天数,<=0 视为 7 } // FanqieConfig 番茄小说配置 @@ -152,6 +159,8 @@ func setDefaults() { // 服务器配置 viper.SetDefault("server.port", "40180") viper.SetDefault("server.host", "0.0.0.0") + viper.SetDefault("server.request_log.enabled", false) + viper.SetDefault("server.request_log.retention_days", 7) // 番茄小说配置 viper.SetDefault("fanqie.algorithm", "gorgon") diff --git a/internal/fqapi/cache_manager.go b/internal/fqapi/cache_manager.go index 217de69..6df86b1 100644 --- a/internal/fqapi/cache_manager.go +++ b/internal/fqapi/cache_manager.go @@ -491,28 +491,6 @@ func ResetContentRequestCount() { contentRequestCount.Store(0) } // GetContentRequestCount 获取当前访问计数 func GetContentRequestCount() int { return int(contentRequestCount.Load()) } -// VacuumDatabase 压缩数据库,释放磁盘空间 -// ponytail: 删了全局串行锁,VACUUM 与写操作的并发冲突由 busy_timeout(5s) 兜底,失败仅记日志 -func VacuumDatabase() error { - if cacheDB == nil { - return fmt.Errorf("数据库未初始化") - } - - cacheLogger.Info("开始执行数据库压缩...") - if dbDriver == "pgx" { - cacheLogger.Info("PostgreSQL 由 autovacuum 自动管理,无需手动压缩") - return nil - } - _, err := cacheDB.Exec("VACUUM") - if err != nil { - cacheLogger.Error("数据库压缩失败: " + err.Error()) - return err - } - - cacheLogger.Info("数据库压缩完成,磁盘空间已释放") - return nil -} - // GetDatabaseStats 获取数据库统计信息 func GetDatabaseStats() (map[string]interface{}, error) { if cacheDB == nil { diff --git a/internal/fqapi/request_log.go b/internal/fqapi/request_log.go new file mode 100644 index 0000000..4221f2b --- /dev/null +++ b/internal/fqapi/request_log.go @@ -0,0 +1,218 @@ +package fqapi + +import ( + "fanqie/internal/config" + "fmt" + "time" +) + +// 请求日志:只记元数据(时间/IP/方法/路径/状态/耗时)与错误响应摘要,不存响应体。 +// 全量响应与正文缓存重复且体积不可控;要看重放一个请求即可。 + +// RequestLogEntry 一条请求日志 +type RequestLogEntry struct { + Ts int64 `json:"ts"` + IP string `json:"ip"` + Method string `json:"method"` + Path string `json:"path"` + Query string `json:"query"` // 已脱敏(key 参数替换为 ***) + Status int `json:"status"` + DurationMs int64 `json:"duration_ms"` + ErrorMsg string `json:"error_msg"` // 仅 status>=400 时截断保留 +} + +// ErrMsgMax 错误响应摘要的最大保留字节数 +const ErrMsgMax = 4096 + +const ( + reqLogChanSize = 256 // channel 满则丢弃,日志绝不阻塞请求 + reqLogFlushBatch = 50 // 攒批上限 + reqLogFlushEvery = time.Second // 兜底 flush 间隔 + reqLogCleanEvery = time.Hour // 过期清理间隔 +) + +var ( + reqLogCh chan RequestLogEntry + reqLogQuit chan struct{} + reqLogDone chan struct{} +) + +// InitRequestLog 建表并启动后台落库协程(未启用时为空操作) +func InitRequestLog() error { + if !requestLogEnabled() { + return nil + } + + ddl := `CREATE TABLE IF NOT EXISTS request_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + ip TEXT, + method TEXT, + path TEXT, + query TEXT, + status INTEGER, + duration_ms INTEGER, + error_msg TEXT + )` + if dbDriver == "pgx" { + ddl = `CREATE TABLE IF NOT EXISTS request_log ( + id BIGSERIAL PRIMARY KEY, + ts BIGINT NOT NULL, + ip TEXT, + method TEXT, + path TEXT, + query TEXT, + status INTEGER, + duration_ms BIGINT, + error_msg TEXT + )` + } + if _, err := cacheDB.Exec(ddl); err != nil { + return fmt.Errorf("创建request_log表失败: %w", err) + } + if _, err := cacheDB.Exec(`CREATE INDEX IF NOT EXISTS idx_request_log_ts ON request_log(ts)`); err != nil { + cacheLogger.Warning("创建request_log索引失败: " + err.Error()) + } + + reqLogCh = make(chan RequestLogEntry, reqLogChanSize) + reqLogQuit = make(chan struct{}) + reqLogDone = make(chan struct{}) + go reqLogWorker() + cacheLogger.Info("请求日志已启用") + return nil +} + +// CloseRequestLog 停止后台协程并落盘剩余日志 +func CloseRequestLog() { + if reqLogQuit == nil { + return + } + close(reqLogQuit) + <-reqLogDone + reqLogQuit = nil +} + +// LogRequest 非阻塞投递一条请求日志(未启用/队列满时静默丢弃) +func LogRequest(e RequestLogEntry) { + if reqLogCh == nil { + return + } + select { + case reqLogCh <- e: + default: + } +} + +// GetRecentRequests 查询最近 limit 条请求日志 +func GetRecentRequests(limit int) []RequestLogEntry { + if cacheDB == nil || reqLogCh == nil { + return nil + } + rows, err := cacheDB.Query( + q("SELECT ts, ip, method, path, query, status, duration_ms, COALESCE(error_msg, '') FROM request_log ORDER BY id DESC LIMIT ?"), limit) + if err != nil { + cacheLogger.Warning("查询请求日志失败: " + err.Error()) + return nil + } + defer rows.Close() + + entries := make([]RequestLogEntry, 0, limit) + for rows.Next() { + var e RequestLogEntry + if err := rows.Scan(&e.Ts, &e.IP, &e.Method, &e.Path, &e.Query, &e.Status, &e.DurationMs, &e.ErrorMsg); err != nil { + cacheLogger.Warning("读取请求日志失败: " + err.Error()) + break + } + entries = append(entries, e) + } + return entries +} + +// reqLogWorker 攒批落库 + 定期清理过期日志 +func reqLogWorker() { + defer close(reqLogDone) + + batch := make([]RequestLogEntry, 0, reqLogFlushBatch) + flush := func() { + if len(batch) == 0 { + return + } + if err := insertRequestLogs(batch); err != nil { + cacheLogger.Warning("写入请求日志失败: " + err.Error()) + } + batch = batch[:0] + } + defer flush() + + ticker := time.NewTicker(reqLogFlushEvery) + cleanTicker := time.NewTicker(reqLogCleanEvery) + defer ticker.Stop() + defer cleanTicker.Stop() + + for { + select { + case e := <-reqLogCh: + batch = append(batch, e) + if len(batch) >= reqLogFlushBatch { + flush() + } + case <-ticker.C: + flush() + case <-cleanTicker.C: + cleanupRequestLog() + case <-reqLogQuit: + // 排空 channel 后退出 + for { + select { + case e := <-reqLogCh: + batch = append(batch, e) + default: + return + } + } + } + } +} + +// insertRequestLogs 事务批量写入 +func insertRequestLogs(batch []RequestLogEntry) error { + tx, err := cacheDB.Begin() + if err != nil { + return err + } + stmt, err := tx.Prepare(q("INSERT INTO request_log (ts, ip, method, path, query, status, duration_ms, error_msg) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")) + if err != nil { + tx.Rollback() + return err + } + for _, e := range batch { + if _, err := stmt.Exec(e.Ts, e.IP, e.Method, e.Path, e.Query, e.Status, e.DurationMs, e.ErrorMsg); err != nil { + tx.Rollback() + return err + } + } + return tx.Commit() +} + +// cleanupRequestLog 删除超过保留期的日志 +func cleanupRequestLog() { + if !requestLogEnabled() { + return + } + days := 7 + if cfg := config.GetAppConfig(); cfg != nil && cfg.Server.RequestLog.RetentionDays > 0 { + days = cfg.Server.RequestLog.RetentionDays + } + cutoff := time.Now().Unix() - int64(days)*86400 + if result, err := cacheDB.Exec(q("DELETE FROM request_log WHERE ts < ?"), cutoff); err != nil { + cacheLogger.Warning("清理请求日志失败: " + err.Error()) + } else if n, _ := result.RowsAffected(); n > 0 { + cacheLogger.Info(fmt.Sprintf("已清理 %d 条过期请求日志(保留 %d 天)", n, days)) + } +} + +// requestLogEnabled 配置启用且数据库可用 +func requestLogEnabled() bool { + cfg := config.GetAppConfig() + return cfg != nil && cfg.Server.RequestLog.Enabled && cacheDB != nil +} diff --git a/internal/handlers/common.go b/internal/handlers/common.go index 6aa3f0e..eb43981 100644 --- a/internal/handlers/common.go +++ b/internal/handlers/common.go @@ -8,6 +8,7 @@ import ( "fmt" "net" "net/http" + "regexp" "strings" "sync" "sync/atomic" @@ -212,6 +213,60 @@ func OptionalAccessKey(handler http.HandlerFunc) http.HandlerFunc { 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") @@ -227,22 +282,3 @@ func HandleMain(w http.ResponseWriter, r *http.Request) { } json.NewEncoder(w).Encode(response) } - -// HandleSelfInfo 设备信息处理 -func HandleSelfInfo(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - - // 获取设备池统计信息 - pool := fqapi.GetDeviceKeyPool() - poolStats := pool.GetStats() - - response := map[string]interface{}{ - "code": 0, - "message": "SUCCESS", - "data": map[string]interface{}{ - "device_pool_enabled": pool.IsEnabled(), - "device_pool_stats": poolStats, - }, - } - json.NewEncoder(w).Encode(response) -} diff --git a/internal/handlers/status.go b/internal/handlers/status.go index 722aa31..b8d57b8 100644 --- a/internal/handlers/status.go +++ b/internal/handlers/status.go @@ -2,7 +2,6 @@ package handlers import ( _ "embed" - "encoding/json" "fanqie/internal/algos" "fanqie/internal/config" "fanqie/internal/fqapi" @@ -12,35 +11,6 @@ import ( "time" ) -// HandleVacuum 处理数据库压缩请求 -func HandleVacuum(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - - // 只允许 POST 请求 - if r.Method != http.MethodPost { - json.NewEncoder(w).Encode(map[string]interface{}{ - "success": false, - "message": "仅支持 POST 请求", - }) - return - } - - // 执行 VACUUM - err := fqapi.VacuumDatabase() - if err != nil { - json.NewEncoder(w).Encode(map[string]interface{}{ - "success": false, - "message": "压缩失败: " + err.Error(), - }) - return - } - - json.NewEncoder(w).Encode(map[string]interface{}{ - "success": true, - "message": "数据库压缩完成", - }) -} - // 状态页模板(embed 内嵌,动态数据由 html/template 自动转义) // //go:embed templates/status.html @@ -86,12 +56,28 @@ func HandleStatus(w http.ResponseWriter, r *http.Request) { // 获取设备密钥池状态 poolStatus := getDeviceKeyPoolStatus() + // 获取最近请求(日志未启用时为空) + var recent []map[string]interface{} + for _, e := range fqapi.GetRecentRequests(20) { + recent = append(recent, map[string]interface{}{ + "Time": time.Unix(e.Ts, 0).Format("15:04:05"), + "IP": e.IP, + "Method": e.Method, + "Path": e.Path, + "Query": truncateStr(e.Query, 80), + "Status": e.Status, + "IsErr": e.Status >= 400, + "Duration": fmt.Sprintf("%dms", e.DurationMs), + }) + } + statusTmpl.Execute(w, map[string]interface{}{ "NowTime": time.Now().Format("2006-01-02 15:04:05"), "Uptime": uptimeStr, "StartTime": startTime.Format("2006-01-02 15:04:05"), "Signature": signatureDetails, "DBTotal": getDBTotalSize(), + "DBUsedPct": getDBUsagePercentage(), "DBUsed": getDBUsedSize(), "DBFree": getDBFreeSize(), "PoolStatus": poolStatus["status"], @@ -112,9 +98,18 @@ func HandleStatus(w http.ResponseWriter, r *http.Request) { "CacheStatus": getCacheStatus(), "CleanupStatus": getCleanupStatus(), "CleanupInterval": getCleanupInterval(), + "RecentRequests": recent, }) } +// truncateStr 截断字符串,超长加省略号 +func truncateStr(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} + // getCacheStatus 获取缓存状态 func getCacheStatus() string { cfg := config.GetConfig() diff --git a/internal/handlers/templates/status.html b/internal/handlers/templates/status.html index 4472675..3e8f99f 100644 --- a/internal/handlers/templates/status.html +++ b/internal/handlers/templates/status.html @@ -1,757 +1,790 @@ - + +
-运行状态监控面板
+| 时间 | +方法 | +路径 | +IP | +状态 | +耗时 | +
|---|---|---|---|---|---|
| {{.Time}} | +{{.Method}} | +{{.Path}}{{if .Query}} ?{{.Query}}{{end}} | +{{.IP}} | +{{.Status}} | +{{.Duration}} | +
server.request_log.enabled 设为 true 后重启。