✨ 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
+988
-766
No files matched your search
@@ -54,3 +54,4 @@ fanqie-*
|
||||
|
||||
# 环境变量(含真实密码,勿提交)
|
||||
.env
|
||||
.finesse/
|
||||
@@ -138,8 +138,6 @@ go run ./cmd/fanqie
|
||||
| `GET /` | GET | 服务首页 |
|
||||
| `GET /healthz` | GET | 存活探针(不校验密钥,供容器/反代使用) |
|
||||
| `GET /status` | GET | 服务状态(需 AccessKey) |
|
||||
| `GET /self-info` | GET | 自我信息(需 AccessKey) |
|
||||
| `POST /vacuum` | POST | 数据库压缩(需 AccessKey) |
|
||||
|
||||
### 书籍相关
|
||||
|
||||
@@ -240,6 +238,12 @@ server:
|
||||
access_key: "" # 访问密钥(可选)
|
||||
protect_all: false # 业务接口也要求访问密钥(公网部署建议开启)
|
||||
|
||||
# 请求日志:记录 时间/IP/方法/路径/状态/耗时(query 中的 key 自动脱敏),
|
||||
# 4xx/5xx 额外保留错误响应摘要(前 4KB);不记录响应体。/status 页可查看最近 20 条
|
||||
request_log:
|
||||
enabled: false # 是否启用
|
||||
retention_days: 7 # 保留天数,自动清理
|
||||
|
||||
# 番茄小说配置
|
||||
fanqie:
|
||||
# 签名算法: "gorgon" / "8404"
|
||||
|
||||
+8
-4
@@ -56,6 +56,12 @@ func main() {
|
||||
fqapi.StartCacheCleanup()
|
||||
defer fqapi.StopCacheCleanup() // 程序退出时停止清理任务
|
||||
|
||||
// 启动请求日志(server.request_log.enabled 开启时生效)
|
||||
if err := fqapi.InitRequestLog(); err != nil {
|
||||
logger.Warning("请求日志初始化失败: " + err.Error())
|
||||
}
|
||||
defer fqapi.CloseRequestLog() // 程序退出时落盘剩余日志
|
||||
|
||||
// 初始化算法切换器
|
||||
initAlgorithm()
|
||||
|
||||
@@ -139,14 +145,12 @@ func initDeviceKeyPool() error {
|
||||
func setupRoutes() {
|
||||
http.HandleFunc("/", handlers.HandleMain)
|
||||
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)
|
||||
|
||||
// API端点:限流 + protect_all 鉴权链(代理路由不鉴权,URL 由服务端生成不含 key)
|
||||
// API端点:请求日志 + 限流 + protect_all 鉴权链(代理路由不鉴权,URL 由服务端生成不含 key)
|
||||
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("/detail", wrap(handlers.HandleDetail))
|
||||
|
||||
+6
-1
@@ -4,7 +4,12 @@
|
||||
server:
|
||||
port: "40180" # 监听端口
|
||||
host: "0.0.0.0" # 监听地址
|
||||
access_key: "" # 访问密钥,为空则不启用验证(用于 /status 和 /self-info)
|
||||
access_key: "" # 访问密钥,为空则不启用验证(用于 /status)
|
||||
|
||||
# 请求日志(只记元数据与错误摘要,不存响应体;在 /status 页"最近请求"展示)
|
||||
request_log:
|
||||
enabled: false
|
||||
retention_days: 7 # 日志保留天数,自动清理
|
||||
|
||||
# 番茄小说配置
|
||||
fanqie:
|
||||
|
||||
@@ -24,6 +24,13 @@ type ServerConfig struct {
|
||||
AccessKey string `mapstructure:"access_key"`
|
||||
ProtectAll bool `mapstructure:"protect_all"` // 业务接口也要求访问密钥(公网部署建议开启)
|
||||
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 番茄小说配置
|
||||
@@ -152,6 +159,8 @@ func setDefaults() {
|
||||
// 服务器配置
|
||||
viper.SetDefault("server.port", "40180")
|
||||
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")
|
||||
|
||||
@@ -491,28 +491,6 @@ func ResetContentRequestCount() { contentRequestCount.Store(0) }
|
||||
// GetContentRequestCount 获取当前访问计数
|
||||
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 获取数据库统计信息
|
||||
func GetDatabaseStats() (map[string]interface{}, error) {
|
||||
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"
|
||||
"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
+2
-63
@@ -8,7 +8,7 @@ info:
|
||||
|
||||
多数接口直接转发上游 JSON(结构随上游版本变化,`data` 字段以实际返回为准);
|
||||
错误统一返回 `{"code": -1, "msg": "..."}`。
|
||||
需要鉴权的端点(`/status`、`/self-info`、`/vacuum`)通过查询参数 `key`
|
||||
需要鉴权的端点(`/status`)通过查询参数 `key`
|
||||
或请求头 `X-Access-Key` 携带访问密钥,未配置 `server.access_key` 时不启用验证。
|
||||
license:
|
||||
name: MIT
|
||||
@@ -18,7 +18,7 @@ servers:
|
||||
description: 本地开发
|
||||
tags:
|
||||
- name: 系统
|
||||
description: 服务状态、设备信息与数据库维护
|
||||
description: 服务状态监控
|
||||
- name: 书籍
|
||||
description: 搜索、详情、目录与批量详情
|
||||
- name: 内容
|
||||
@@ -85,68 +85,7 @@ paths:
|
||||
"401":
|
||||
$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:
|
||||
get:
|
||||
|
||||
Reference in new issue
Block a user