✨ feat(database): 完善请求日志体系并为全表补充时间字段
- request_log 新增 user_agent 列(含旧表 ALTER 迁移)及 ip/status 索引,记录解码后的明文 query(含 key,不脱敏) - 请求日志批量写入改为多值 INSERT 单次往返,替代事务逐条 Exec - 新增按本地时区分天聚合查询,状态页展示今日请求量与近 7 天请求趋势(峰值日高亮) - 新增 TZ 时区支持:.env 配置 TZ 后日志时间戳与状态页全部时间统一生效,二进制内嵌 tzdata 保证容器可用 - 五张表统一补齐 created_at/updated_at 字段,启动时自动补列并按业务时间回填旧行 - device_pool 的 device_header 增加唯一约束;audio_cache 写入改为 UPSERT 修复同 key 重复报错 - 状态页"最近请求"新增 UA 列与限流状态显示;config.yaml 暴露 rate_limit_qps 配置并默认启用请求日志
This commit is contained in:
1 parent
1a4997e031
commit
126a96d744
12 files changed
+277
-55
No files matched your search
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -253,26 +252,38 @@ func LogRequestMiddleware(handler http.HandlerFunc) http.HandlerFunc {
|
||||
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
handler(sw, r)
|
||||
|
||||
query := sanitizeQuery(r.URL.RawQuery)
|
||||
ua := r.UserAgent()
|
||||
if len(ua) > 512 { // 防恶意超长 UA 撑爆日志
|
||||
ua = ua[:512]
|
||||
}
|
||||
fqapi.LogRequest(fqapi.RequestLogEntry{
|
||||
Ts: start.Unix(),
|
||||
IP: clientIP(r),
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
Query: query,
|
||||
Query: decodedQuery(r),
|
||||
Status: sw.status,
|
||||
DurationMs: time.Since(start).Milliseconds(),
|
||||
ErrorMsg: string(sw.errBuf),
|
||||
UserAgent: ua,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// keyParamRe 匹配查询串中独立的 key 参数(accesskey 等不会被误伤)
|
||||
var keyParamRe = regexp.MustCompile(`(?i)(^|&)key=[^&]*`)
|
||||
|
||||
// sanitizeQuery 日志中脱敏:key 参数值替换为 ***(直接替换避免 Encode 把 *** 转义)
|
||||
func sanitizeQuery(rawQuery string) string {
|
||||
return keyParamRe.ReplaceAllString(rawQuery, "${1}key=***")
|
||||
// decodedQuery 重组解码后的查询串(keyword=%E6%B5%8B → keyword=测),日志可读
|
||||
// ponytail: map 遍历乱序、%26 解码后与分隔符同名 —— 展示用途可接受
|
||||
func decodedQuery(r *http.Request) string {
|
||||
vals := r.URL.Query() // 已解码
|
||||
if len(vals) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(vals))
|
||||
for k, vs := range vals {
|
||||
for _, v := range vs {
|
||||
parts = append(parts, k+"="+v)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
// HandleHealth 存活探针(供容器/反向代理使用)
|
||||
|
||||
@@ -68,9 +68,17 @@ func HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"Status": e.Status,
|
||||
"IsErr": e.Status >= 400,
|
||||
"Duration": fmt.Sprintf("%dms", e.DurationMs),
|
||||
"UA": truncateStr(e.UserAgent, 60),
|
||||
})
|
||||
}
|
||||
|
||||
// 近 7 天请求量(日志未启用时为空);最后一天即今日
|
||||
var todayCount int64
|
||||
daily := fqapi.GetDailyRequestStats(7)
|
||||
if n := len(daily); n > 0 {
|
||||
todayCount = daily[n-1].Count
|
||||
}
|
||||
|
||||
statusTmpl.Execute(w, map[string]interface{}{
|
||||
"NowTime": time.Now().Format("2006-01-02 15:04:05"),
|
||||
"Uptime": uptimeStr,
|
||||
@@ -94,6 +102,9 @@ func HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"StatAudio": stats["audio"],
|
||||
"StatVideo": stats["video"],
|
||||
"StatCategory": stats["category"],
|
||||
"TodayCount": todayCount,
|
||||
"DailyStats": daily,
|
||||
"RateLimit": getRateLimitDesc(),
|
||||
"CacheCount": getDBCacheCount(),
|
||||
"CacheStatus": getCacheStatus(),
|
||||
"CleanupStatus": getCleanupStatus(),
|
||||
@@ -110,6 +121,15 @@ func truncateStr(s string, n int) string {
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
// getRateLimitDesc 当前每 IP 限流描述(server.rate_limit_qps,0=不限制)
|
||||
func getRateLimitDesc() string {
|
||||
cfg := config.GetAppConfig()
|
||||
if cfg == nil || cfg.Server.RateLimitQPS <= 0 {
|
||||
return "未启用"
|
||||
}
|
||||
return fmt.Sprintf("%.0f QPS/IP", cfg.Server.RateLimitQPS)
|
||||
}
|
||||
|
||||
// getCacheStatus 获取缓存状态
|
||||
func getCacheStatus() string {
|
||||
cfg := config.GetConfig()
|
||||
|
||||
@@ -186,6 +186,12 @@
|
||||
.strack { height: 5px; border-radius: 999px; background: var(--panel-2); overflow: hidden; }
|
||||
.sfill { display: block; height: 100%; border-radius: inherit; background: var(--accent); width: 0; transition: width 0.7s cubic-bezier(0.22, 0.61, 0.36, 1); }
|
||||
.sval { font-size: 12px; font-weight: 600; color: var(--ink-2); text-align: right; }
|
||||
/* 每日请求行:结构同 srow,宽度服务端算好,不参与 .srow 的 JS 归一化 */
|
||||
.drows { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--border); display: flex; flex-direction: column; gap: 7px; }
|
||||
.drow { display: grid; grid-template-columns: 44px 1fr 52px; gap: 8px; align-items: center; }
|
||||
.dlabel { font-size: 11.5px; color: var(--ink-3); font-variant-numeric: tabular-nums; }
|
||||
.dval { font-size: 12px; font-weight: 600; color: var(--ink-2); text-align: right; }
|
||||
.drow.is-max .dlabel, .drow.is-max .dval { color: var(--accent); }
|
||||
|
||||
/* ── 最近请求表 ─────────────────────── */
|
||||
.req-card { margin-bottom: 14px; overflow: hidden; }
|
||||
@@ -472,6 +478,7 @@
|
||||
<div class="hero-meta">
|
||||
<div class="kv"><span class="k">签名算法</span><span class="v num">{{.Signature}}</span></div>
|
||||
<div class="kv"><span class="k">启动时间</span><span class="v num">{{.StartTime}}</span></div>
|
||||
<div class="kv"><span class="k">限流</span><span class="v num">{{.RateLimit}}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -513,7 +520,7 @@
|
||||
<span class="kpi-label">请求统计</span>
|
||||
</div>
|
||||
<div class="big num" data-count-num="{{.StatTotal}}">{{.StatTotal}}</div>
|
||||
<div class="big-sub">累计业务请求</div>
|
||||
<div class="big-sub">累计业务请求 · 今日 <span class="num">{{.TodayCount}}</span> 次</div>
|
||||
<div class="srows">
|
||||
<div class="srow" data-count="{{.StatSearch}}"><span class="slabel">搜索</span><span class="strack"><i class="sfill"></i></span><span class="sval num">{{.StatSearch}}</span></div>
|
||||
<div class="srow" data-count="{{.StatDetail}}"><span class="slabel">详情</span><span class="strack"><i class="sfill"></i></span><span class="sval num">{{.StatDetail}}</span></div>
|
||||
@@ -524,6 +531,13 @@
|
||||
<div class="srow" data-count="{{.StatVideo}}"><span class="slabel">视频</span><span class="strack"><i class="sfill"></i></span><span class="sval num">{{.StatVideo}}</span></div>
|
||||
<div class="srow" data-count="{{.StatCategory}}"><span class="slabel">分类</span><span class="strack"><i class="sfill"></i></span><span class="sval num">{{.StatCategory}}</span></div>
|
||||
</div>
|
||||
{{if .DailyStats}}
|
||||
<div class="drows">
|
||||
{{range .DailyStats}}
|
||||
<div class="drow{{if .MaxCount}} is-max{{end}}"><span class="dlabel">{{.Day}}</span><span class="strack"><i class="sfill" style="width: {{.Pct}}%"></i></span><span class="dval num">{{.Count}}</span></div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="card kpi">
|
||||
@@ -553,6 +567,7 @@
|
||||
<th>方法</th>
|
||||
<th>路径</th>
|
||||
<th>IP</th>
|
||||
<th>UA</th>
|
||||
<th>状态</th>
|
||||
<th class="r">耗时</th>
|
||||
</tr>
|
||||
@@ -564,6 +579,7 @@
|
||||
<td><span class="mth">{{.Method}}</span></td>
|
||||
<td class="code path-cell" title="{{.Path}}">{{.Path}}{{if .Query}} <span class="q">?{{.Query}}</span>{{end}}</td>
|
||||
<td class="code">{{.IP}}</td>
|
||||
<td class="code" title="{{.UA}}">{{.UA}}</td>
|
||||
<td><span class="stc {{if .IsErr}}stc-err{{else}}stc-ok{{end}}"><i aria-hidden="true"></i>{{.Status}}</span></td>
|
||||
<td class="r num">{{.Duration}}</td>
|
||||
</tr>
|
||||
|
||||
Reference in new issue
Block a user