✨ feat(status): 新增请求日志并重设计状态页

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

No files matched your search

+1
View File
@@ -54,3 +54,4 @@ fanqie-*
# 环境变量(含真实密码,勿提交)
.env
.finesse/
+6 -2
View File
@@ -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"
+8 -4
View File
@@ -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))
+6 -1
View File
@@ -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:
+9
View File
@@ -24,6 +24,13 @@ type ServerConfig struct {
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")
-22
View File
@@ -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 {
+218
View File
@@ -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
}
+55 -19
View File
@@ -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)
}
+25 -30
View File
@@ -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()
File diff suppressed because it is too large. Load diff
+2 -63
View File
@@ -8,7 +8,7 @@ info:
多数接口直接转发上游 JSON(结构随上游版本变化,`data` 字段以实际返回为准);
错误统一返回 `{"code": -1, "msg": "..."}`。
需要鉴权的端点(`/status`、`/self-info`、`/vacuum`)通过查询参数 `key`
需要鉴权的端点(`/status`)通过查询参数 `key`
或请求头 `X-Access-Key` 携带访问密钥,未配置 `server.access_key` 时不启用验证。
license:
name: MIT
@@ -18,7 +18,7 @@ servers:
description: 本地开发
tags:
- name: 系统
description: 服务状态、设备信息与数据库维护
description: 服务状态监控
- name: 书籍
description: 搜索、详情、目录与批量详情
- name: 内容
@@ -85,68 +85,7 @@ paths:
"401":
$ref: "#/components/responses/Unauthorized"
/self-info:
get:
tags: [系统]
summary: 设备密钥池信息
description: 返回设备池启用状态与统计,包含设备密钥等敏感数据。
operationId: getSelfInfo
security:
- accessKeyQuery: []
- accessKeyHeader: []
parameters:
- $ref: "#/components/parameters/AccessKeyQuery"
responses:
"200":
description: 设备池信息
content:
application/json:
schema:
type: object
properties:
code: { type: integer, example: 0 }
message: { type: string, example: SUCCESS }
data:
type: object
properties:
device_pool_enabled: { type: boolean }
device_pool_stats:
type: object
properties:
total: { type: integer }
valid: { type: integer }
invalid: { type: integer }
current_index: { type: integer }
"401":
$ref: "#/components/responses/Unauthorized"
/vacuum:
post:
tags: [系统]
summary: 压缩数据库
description: |
执行数据库压缩以释放磁盘空间。
- SQLite: 执行 VACUUM
- PostgreSQL: 由 autovacuum 自动管理,本操作为空操作
仅支持 POST,GET 请求会被拒绝。
operationId: vacuumDatabase
security:
- accessKeyQuery: []
- accessKeyHeader: []
parameters:
- $ref: "#/components/parameters/AccessKeyQuery"
responses:
"200":
description: 压缩结果
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string, example: 数据库压缩完成 }
"401":
$ref: "#/components/responses/Unauthorized"
/search:
get: