✨ 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
+1060 -838

No files matched your search

+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