✨ 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:
1 parent
bf49805bb6
commit
bfb4290c15
11 files changed
+1046
-824
No files matched your search
@@ -54,3 +54,4 @@ fanqie-*
|
|||||||
|
|
||||||
# 环境变量(含真实密码,勿提交)
|
# 环境变量(含真实密码,勿提交)
|
||||||
.env
|
.env
|
||||||
|
.finesse/
|
||||||
@@ -138,8 +138,6 @@ go run ./cmd/fanqie
|
|||||||
| `GET /` | GET | 服务首页 |
|
| `GET /` | GET | 服务首页 |
|
||||||
| `GET /healthz` | GET | 存活探针(不校验密钥,供容器/反代使用) |
|
| `GET /healthz` | GET | 存活探针(不校验密钥,供容器/反代使用) |
|
||||||
| `GET /status` | GET | 服务状态(需 AccessKey) |
|
| `GET /status` | GET | 服务状态(需 AccessKey) |
|
||||||
| `GET /self-info` | GET | 自我信息(需 AccessKey) |
|
|
||||||
| `POST /vacuum` | POST | 数据库压缩(需 AccessKey) |
|
|
||||||
|
|
||||||
### 书籍相关
|
### 书籍相关
|
||||||
|
|
||||||
@@ -240,6 +238,12 @@ server:
|
|||||||
access_key: "" # 访问密钥(可选)
|
access_key: "" # 访问密钥(可选)
|
||||||
protect_all: false # 业务接口也要求访问密钥(公网部署建议开启)
|
protect_all: false # 业务接口也要求访问密钥(公网部署建议开启)
|
||||||
|
|
||||||
|
# 请求日志:记录 时间/IP/方法/路径/状态/耗时(query 中的 key 自动脱敏),
|
||||||
|
# 4xx/5xx 额外保留错误响应摘要(前 4KB);不记录响应体。/status 页可查看最近 20 条
|
||||||
|
request_log:
|
||||||
|
enabled: false # 是否启用
|
||||||
|
retention_days: 7 # 保留天数,自动清理
|
||||||
|
|
||||||
# 番茄小说配置
|
# 番茄小说配置
|
||||||
fanqie:
|
fanqie:
|
||||||
# 签名算法: "gorgon" / "8404"
|
# 签名算法: "gorgon" / "8404"
|
||||||
|
|||||||
+8
-4
@@ -56,6 +56,12 @@ func main() {
|
|||||||
fqapi.StartCacheCleanup()
|
fqapi.StartCacheCleanup()
|
||||||
defer fqapi.StopCacheCleanup() // 程序退出时停止清理任务
|
defer fqapi.StopCacheCleanup() // 程序退出时停止清理任务
|
||||||
|
|
||||||
|
// 启动请求日志(server.request_log.enabled 开启时生效)
|
||||||
|
if err := fqapi.InitRequestLog(); err != nil {
|
||||||
|
logger.Warning("请求日志初始化失败: " + err.Error())
|
||||||
|
}
|
||||||
|
defer fqapi.CloseRequestLog() // 程序退出时落盘剩余日志
|
||||||
|
|
||||||
// 初始化算法切换器
|
// 初始化算法切换器
|
||||||
initAlgorithm()
|
initAlgorithm()
|
||||||
|
|
||||||
@@ -139,14 +145,12 @@ func initDeviceKeyPool() error {
|
|||||||
func setupRoutes() {
|
func setupRoutes() {
|
||||||
http.HandleFunc("/", handlers.HandleMain)
|
http.HandleFunc("/", handlers.HandleMain)
|
||||||
http.HandleFunc("/status", handlers.RequireAccessKey(handlers.HandleStatus))
|
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)
|
http.HandleFunc("/healthz", handlers.HandleHealth)
|
||||||
|
|
||||||
// API端点:限流 + protect_all 鉴权链(代理路由不鉴权,URL 由服务端生成不含 key)
|
// API端点:请求日志 + 限流 + protect_all 鉴权链(代理路由不鉴权,URL 由服务端生成不含 key)
|
||||||
wrap := func(h http.HandlerFunc) http.HandlerFunc {
|
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("/search", wrap(handlers.HandleSearch))
|
||||||
http.HandleFunc("/detail", wrap(handlers.HandleDetail))
|
http.HandleFunc("/detail", wrap(handlers.HandleDetail))
|
||||||
|
|||||||
+6
-1
@@ -4,7 +4,12 @@
|
|||||||
server:
|
server:
|
||||||
port: "40180" # 监听端口
|
port: "40180" # 监听端口
|
||||||
host: "0.0.0.0" # 监听地址
|
host: "0.0.0.0" # 监听地址
|
||||||
access_key: "" # 访问密钥,为空则不启用验证(用于 /status 和 /self-info)
|
access_key: "" # 访问密钥,为空则不启用验证(用于 /status)
|
||||||
|
|
||||||
|
# 请求日志(只记元数据与错误摘要,不存响应体;在 /status 页"最近请求"展示)
|
||||||
|
request_log:
|
||||||
|
enabled: false
|
||||||
|
retention_days: 7 # 日志保留天数,自动清理
|
||||||
|
|
||||||
# 番茄小说配置
|
# 番茄小说配置
|
||||||
fanqie:
|
fanqie:
|
||||||
|
|||||||
@@ -19,11 +19,18 @@ type Config struct {
|
|||||||
|
|
||||||
// ServerConfig 服务器配置
|
// ServerConfig 服务器配置
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Port string `mapstructure:"port"`
|
Port string `mapstructure:"port"`
|
||||||
Host string `mapstructure:"host"`
|
Host string `mapstructure:"host"`
|
||||||
AccessKey string `mapstructure:"access_key"`
|
AccessKey string `mapstructure:"access_key"`
|
||||||
ProtectAll bool `mapstructure:"protect_all"` // 业务接口也要求访问密钥(公网部署建议开启)
|
ProtectAll bool `mapstructure:"protect_all"` // 业务接口也要求访问密钥(公网部署建议开启)
|
||||||
RateLimitQPS float64 `mapstructure:"rate_limit_qps"` // 每 IP 每秒请求上限,0=不限制
|
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 番茄小说配置
|
// FanqieConfig 番茄小说配置
|
||||||
@@ -152,6 +159,8 @@ func setDefaults() {
|
|||||||
// 服务器配置
|
// 服务器配置
|
||||||
viper.SetDefault("server.port", "40180")
|
viper.SetDefault("server.port", "40180")
|
||||||
viper.SetDefault("server.host", "0.0.0.0")
|
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")
|
viper.SetDefault("fanqie.algorithm", "gorgon")
|
||||||
|
|||||||
@@ -491,28 +491,6 @@ func ResetContentRequestCount() { contentRequestCount.Store(0) }
|
|||||||
// GetContentRequestCount 获取当前访问计数
|
// GetContentRequestCount 获取当前访问计数
|
||||||
func GetContentRequestCount() int { return int(contentRequestCount.Load()) }
|
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 获取数据库统计信息
|
// GetDatabaseStats 获取数据库统计信息
|
||||||
func GetDatabaseStats() (map[string]interface{}, error) {
|
func GetDatabaseStats() (map[string]interface{}, error) {
|
||||||
if cacheDB == nil {
|
if cacheDB == nil {
|
||||||
|
|||||||
@@ -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
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -212,6 +213,60 @@ func OptionalAccessKey(handler http.HandlerFunc) http.HandlerFunc {
|
|||||||
return RequireAccessKey(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 存活探针(供容器/反向代理使用)
|
// HandleHealth 存活探针(供容器/反向代理使用)
|
||||||
func HandleHealth(w http.ResponseWriter, r *http.Request) {
|
func HandleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
fmt.Fprint(w, "ok")
|
fmt.Fprint(w, "ok")
|
||||||
@@ -227,22 +282,3 @@ func HandleMain(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
json.NewEncoder(w).Encode(response)
|
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
@@ -2,7 +2,6 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
|
||||||
"fanqie/internal/algos"
|
"fanqie/internal/algos"
|
||||||
"fanqie/internal/config"
|
"fanqie/internal/config"
|
||||||
"fanqie/internal/fqapi"
|
"fanqie/internal/fqapi"
|
||||||
@@ -12,35 +11,6 @@ import (
|
|||||||
"time"
|
"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 自动转义)
|
// 状态页模板(embed 内嵌,动态数据由 html/template 自动转义)
|
||||||
//
|
//
|
||||||
//go:embed templates/status.html
|
//go:embed templates/status.html
|
||||||
@@ -86,12 +56,28 @@ func HandleStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
// 获取设备密钥池状态
|
// 获取设备密钥池状态
|
||||||
poolStatus := getDeviceKeyPoolStatus()
|
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{}{
|
statusTmpl.Execute(w, map[string]interface{}{
|
||||||
"NowTime": time.Now().Format("2006-01-02 15:04:05"),
|
"NowTime": time.Now().Format("2006-01-02 15:04:05"),
|
||||||
"Uptime": uptimeStr,
|
"Uptime": uptimeStr,
|
||||||
"StartTime": startTime.Format("2006-01-02 15:04:05"),
|
"StartTime": startTime.Format("2006-01-02 15:04:05"),
|
||||||
"Signature": signatureDetails,
|
"Signature": signatureDetails,
|
||||||
"DBTotal": getDBTotalSize(),
|
"DBTotal": getDBTotalSize(),
|
||||||
|
"DBUsedPct": getDBUsagePercentage(),
|
||||||
"DBUsed": getDBUsedSize(),
|
"DBUsed": getDBUsedSize(),
|
||||||
"DBFree": getDBFreeSize(),
|
"DBFree": getDBFreeSize(),
|
||||||
"PoolStatus": poolStatus["status"],
|
"PoolStatus": poolStatus["status"],
|
||||||
@@ -112,9 +98,18 @@ func HandleStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
"CacheStatus": getCacheStatus(),
|
"CacheStatus": getCacheStatus(),
|
||||||
"CleanupStatus": getCleanupStatus(),
|
"CleanupStatus": getCleanupStatus(),
|
||||||
"CleanupInterval": getCleanupInterval(),
|
"CleanupInterval": getCleanupInterval(),
|
||||||
|
"RecentRequests": recent,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// truncateStr 截断字符串,超长加省略号
|
||||||
|
func truncateStr(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "…"
|
||||||
|
}
|
||||||
|
|
||||||
// getCacheStatus 获取缓存状态
|
// getCacheStatus 获取缓存状态
|
||||||
func getCacheStatus() string {
|
func getCacheStatus() string {
|
||||||
cfg := config.GetConfig()
|
cfg := config.GetConfig()
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+2
-63
@@ -8,7 +8,7 @@ info:
|
|||||||
|
|
||||||
多数接口直接转发上游 JSON(结构随上游版本变化,`data` 字段以实际返回为准);
|
多数接口直接转发上游 JSON(结构随上游版本变化,`data` 字段以实际返回为准);
|
||||||
错误统一返回 `{"code": -1, "msg": "..."}`。
|
错误统一返回 `{"code": -1, "msg": "..."}`。
|
||||||
需要鉴权的端点(`/status`、`/self-info`、`/vacuum`)通过查询参数 `key`
|
需要鉴权的端点(`/status`)通过查询参数 `key`
|
||||||
或请求头 `X-Access-Key` 携带访问密钥,未配置 `server.access_key` 时不启用验证。
|
或请求头 `X-Access-Key` 携带访问密钥,未配置 `server.access_key` 时不启用验证。
|
||||||
license:
|
license:
|
||||||
name: MIT
|
name: MIT
|
||||||
@@ -18,7 +18,7 @@ servers:
|
|||||||
description: 本地开发
|
description: 本地开发
|
||||||
tags:
|
tags:
|
||||||
- name: 系统
|
- name: 系统
|
||||||
description: 服务状态、设备信息与数据库维护
|
description: 服务状态监控
|
||||||
- name: 书籍
|
- name: 书籍
|
||||||
description: 搜索、详情、目录与批量详情
|
description: 搜索、详情、目录与批量详情
|
||||||
- name: 内容
|
- name: 内容
|
||||||
@@ -85,68 +85,7 @@ paths:
|
|||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$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:
|
/search:
|
||||||
get:
|
get:
|
||||||
|
|||||||
Reference in new issue
Block a user