Files
fanqie-go/internal/handlers/status.go
T
chenxin e3d96ca637 ✨ 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 配置并默认启用请求日志
- deploy.sh 同名版本默认拒绝覆盖,新增 --force/-f 参数显式允许覆盖
2026-10-09 22:12:17 +08:00

286 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
_ "embed"
"fanqie/internal/algos"
"fanqie/internal/config"
"fanqie/internal/fqapi"
"fmt"
"html/template"
"net/http"
"time"
)
// 状态页模板(embed 内嵌,动态数据由 html/template 自动转义)
//
//go:embed templates/status.html
var statusHTML string
var statusTmpl = template.Must(template.New("status").Parse(statusHTML))
// HandleStatus 状态页面处理
func HandleStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
uptime := time.Since(startTime)
// 格式化运行时间
days := int(uptime.Hours() / 24)
hours := int(uptime.Hours()) % 24
minutes := int(uptime.Minutes()) % 60
seconds := int(uptime.Seconds()) % 60
var uptimeStr string
if days > 0 {
uptimeStr = fmt.Sprintf("%d天 %d小时 %d分钟 %d秒", days, hours, minutes, seconds)
} else if hours > 0 {
uptimeStr = fmt.Sprintf("%d小时 %d分钟 %d秒", hours, minutes, seconds)
} else if minutes > 0 {
uptimeStr = fmt.Sprintf("%d分钟 %d秒", minutes, seconds)
} else {
uptimeStr = fmt.Sprintf("%d秒", seconds)
}
// 获取签名信息
currentSign := algos.GlobalSignatureSwitcher.GetCurrentMethod()
// 生成签名详情显示
var signatureDetails string
if currentSign == "sign2" {
signatureDetails = "X-Gorgon + X-Khronos"
} else {
signatureDetails = "X-Gorgon + X-Ladon + X-Argus + X-Khronos"
}
// 获取统计数据
stats := getStatsSnapshot()
// 获取设备密钥池状态
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),
"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,
"StartTime": startTime.Format("2006-01-02 15:04:05"),
"Signature": signatureDetails,
"DBTotal": getDBTotalSize(),
"DBUsedPct": getDBUsagePercentage(),
"DBUsed": getDBUsedSize(),
"DBFree": getDBFreeSize(),
"PoolStatus": poolStatus["status"],
"PoolTotal": poolStatus["total"],
"PoolValid": poolStatus["valid"],
"PoolInvalid": poolStatus["invalid"],
"PoolIndex": poolStatus["current_index"],
"StatTotal": stats["total"],
"StatSearch": stats["search"],
"StatDetail": stats["detail"],
"StatCatalog": stats["catalog"],
"StatContent": stats["content"],
"StatManga": stats["manga"],
"StatAudio": stats["audio"],
"StatVideo": stats["video"],
"StatCategory": stats["category"],
"TodayCount": todayCount,
"DailyStats": daily,
"RateLimit": getRateLimitDesc(),
"CacheCount": getDBCacheCount(),
"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] + "…"
}
// 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()
if cfg != nil && cfg.Cache.Enabled {
return "已启用"
}
return "已禁用"
}
// getCleanupStatus 获取清理状态
func getCleanupStatus() string {
cfg := config.GetConfig()
if cfg != nil && cfg.Cache.CleanupEnabled {
return "已启用"
}
return "已禁用"
}
// getCleanupInterval 获取清理间隔
func getCleanupInterval() string {
cfg := config.GetConfig()
if cfg != nil {
seconds := cfg.Cache.CleanupSeconds
if seconds == 0 {
return "不清理"
} else if seconds < 60 {
return fmt.Sprintf("%d秒", seconds)
} else if seconds < 3600 {
return fmt.Sprintf("%d分钟", seconds/60)
} else {
return fmt.Sprintf("%d小时", seconds/3600)
}
}
return "未知"
}
// formatBytes 格式化字节大小
func formatBytes(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.2f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
// getDBCacheCount 获取数据库缓存记录数
func getDBCacheCount() int {
stats, err := fqapi.GetDatabaseStats()
if err != nil || stats == nil {
return 0
}
if count, ok := stats["cache_count"].(int); ok {
return count
}
return 0
}
// getDBTotalSize 获取数据库总大小
func getDBTotalSize() string {
stats, err := fqapi.GetDatabaseStats()
if err != nil || stats == nil {
return "未知"
}
if size, ok := stats["total_size_bytes"].(int64); ok {
return formatBytes(size)
}
return "未知"
}
// getDBUsedSize 获取数据库已用空间
func getDBUsedSize() string {
stats, err := fqapi.GetDatabaseStats()
if err != nil || stats == nil {
return "未知"
}
if size, ok := stats["used_size_bytes"].(int64); ok {
return formatBytes(size)
}
return "未知"
}
// getDBFreeSize 获取数据库空闲空间(带颜色提示)
func getDBFreeSize() string {
stats, err := fqapi.GetDatabaseStats()
if err != nil || stats == nil {
return "未知"
}
freeSize, okFree := stats["free_size_bytes"].(int64)
totalSize, okTotal := stats["total_size_bytes"].(int64)
if !okFree {
return "未知"
}
if freeSize == 0 {
return "0 B"
}
freeSizeStr := formatBytes(freeSize)
// 如果有总大小,计算并显示百分比
if okTotal && totalSize > 0 {
percentage := float64(freeSize) * 100 / float64(totalSize)
return fmt.Sprintf("%s (%.1f%%)", freeSizeStr, percentage)
}
return freeSizeStr
}
// getDBUsagePercentage 获取数据库使用率
func getDBUsagePercentage() float64 {
stats, err := fqapi.GetDatabaseStats()
if err != nil || stats == nil {
return 0
}
usedSize, okUsed := stats["used_size_bytes"].(int64)
totalSize, okTotal := stats["total_size_bytes"].(int64)
if !okUsed || !okTotal || totalSize == 0 {
return 0
}
return float64(usedSize) * 100 / float64(totalSize)
}
// getDeviceKeyPoolStatus 获取设备密钥池状态
func getDeviceKeyPoolStatus() map[string]interface{} {
pool := fqapi.GetDeviceKeyPool()
if !pool.IsEnabled() {
return map[string]interface{}{
"status": "未启用",
"total": 0,
"valid": 0,
"invalid": 0,
"current_index": 0,
}
}
stats := pool.GetStats()
return map[string]interface{}{
"status": "已启用",
"total": stats["total"],
"valid": stats["valid"],
"invalid": stats["invalid"],
"current_index": stats["current_index"],
}
}