✨ 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
+1060
-838
No files matched your search
+55
-19
@@ -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
@@ -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
Reference in new issue
Block a user