diff --git a/.gitignore b/.gitignore index 4d79f69..7a97fb0 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,4 @@ fanqie-* # 环境变量(含真实密码,勿提交) .env +.finesse/ diff --git a/README.md b/README.md index d60a6d5..edcbb5d 100644 --- a/README.md +++ b/README.md @@ -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" diff --git a/cmd/fanqie/main.go b/cmd/fanqie/main.go index 74a4e72..abaf33b 100644 --- a/cmd/fanqie/main.go +++ b/cmd/fanqie/main.go @@ -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)) diff --git a/config/config.yaml b/config/config.yaml index 7954a73..a92bb05 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -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: diff --git a/internal/config/config.go b/internal/config/config.go index 1c5d3f8..c3e0909 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,11 +19,18 @@ type Config struct { // ServerConfig 服务器配置 type ServerConfig struct { - Port string `mapstructure:"port"` - Host string `mapstructure:"host"` - AccessKey string `mapstructure:"access_key"` - ProtectAll bool `mapstructure:"protect_all"` // 业务接口也要求访问密钥(公网部署建议开启) - RateLimitQPS float64 `mapstructure:"rate_limit_qps"` // 每 IP 每秒请求上限,0=不限制 + Port string `mapstructure:"port"` + Host string `mapstructure:"host"` + 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") diff --git a/internal/fqapi/cache_manager.go b/internal/fqapi/cache_manager.go index 217de69..6df86b1 100644 --- a/internal/fqapi/cache_manager.go +++ b/internal/fqapi/cache_manager.go @@ -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 { diff --git a/internal/fqapi/request_log.go b/internal/fqapi/request_log.go new file mode 100644 index 0000000..4221f2b --- /dev/null +++ b/internal/fqapi/request_log.go @@ -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 +} diff --git a/internal/handlers/common.go b/internal/handlers/common.go index 6aa3f0e..eb43981 100644 --- a/internal/handlers/common.go +++ b/internal/handlers/common.go @@ -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) -} diff --git a/internal/handlers/status.go b/internal/handlers/status.go index 722aa31..b8d57b8 100644 --- a/internal/handlers/status.go +++ b/internal/handlers/status.go @@ -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() diff --git a/internal/handlers/templates/status.html b/internal/handlers/templates/status.html index 4472675..3e8f99f 100644 --- a/internal/handlers/templates/status.html +++ b/internal/handlers/templates/status.html @@ -1,757 +1,790 @@ - + + - 番茄小说API - 运行状态 + 番茄小说 API · 运行状态 -
-
-

番茄小说API

-

运行状态监控面板

+
+
+
+ +
+

番茄小说 API

+ 自部署实例 · 运行状态 +
+ {{.NowTime}} +
+ +
+
+ 服务运行中 +
持续运行
+
{{.Uptime}}
+
+
+
签名算法{{.Signature}}
+
启动时间{{.StartTime}}
+
+
+ +
+
+
+ + 数据库 +
+
{{.DBTotal}}
+
数据库文件大小
+
+
空间占用{{printf "%.1f" .DBUsedPct}}%
+
+
+
已用空间{{.DBUsed}}
+
空闲空间{{.DBFree}}
+
+ +
+
+ + 设备密钥池 +
+
{{.PoolValid}} / {{.PoolTotal}}
+
有效设备 / 总设备数
+
{{.PoolStatus}}
+
+
有效率
+
+
+
无效设备{{.PoolInvalid}}
+
当前索引{{.PoolIndex}}
+
+ +
+
+ + 请求统计 +
+
{{.StatTotal}}
+
累计业务请求
+
+
搜索{{.StatSearch}}
+
详情{{.StatDetail}}
+
目录{{.StatCatalog}}
+
正文{{.StatContent}}
+
漫画{{.StatManga}}
+
音频{{.StatAudio}}
+
视频{{.StatVideo}}
+
分类{{.StatCategory}}
+
+
+ +
+
+ + 缓存 +
+
{{.CacheCount}}
+
正文缓存条数
+
{{.CacheStatus}}
+
自动清理{{.CleanupStatus}}
+
清理间隔{{.CleanupInterval}}
+
+
+ +
+
+ 最近请求 + 20 条 · 3 秒自动刷新 +
+ {{if .RecentRequests}} +
+ + + + + + + + + + + + + {{range .RecentRequests}} + + + + + + + + + {{end}} + +
时间方法路径IP状态耗时
{{.Time}}{{.Method}}{{.Path}}{{if .Query}} ?{{.Query}}{{end}}{{.IP}}{{.Status}}{{.Duration}}
+
+ {{else}} +
+
暂无请求记录
+
未开启请求日志,或服务启动后还没有收到业务请求。
可在配置文件中将 server.request_log.enabled 设为 true 后重启。
+
+ {{end}} +
- -
-
-
● 运行状态
-
- 服务器时间 - {{.NowTime}} -
-
- 运行时长 - {{.Uptime}} -
-
- 启动时间 - {{.StartTime}} -
-
- 签名方法 - {{.Signature}} -
-
- 数据库大小 - {{.DBTotal}} -
-
- 已用空间 - {{.DBUsed}} -
-
- 空闲空间 - {{.DBFree}} 立即压缩 -
+ +
+
+ API 接口文档 + 分组可折叠 · 点"测试"直接调试
- -
-
◉ 设备密钥池
-
- 池状态 - {{.PoolStatus}} -
-
- 总设备数 - {{.PoolTotal}} -
-
- 有效设备 - {{.PoolValid}} 可用 -
-
- 无效设备 - {{.PoolInvalid}} -
-
- 当前索引 - {{.PoolIndex}} -
-
- -
-
■ 请求统计
-
- 总请求数 - {{.StatTotal}} -
-
- 搜索请求 - {{.StatSearch}} -
-
- 详情请求 - {{.StatDetail}} -
-
- 目录请求 - {{.StatCatalog}} -
-
- 内容请求 - {{.StatContent}} -
-
- 漫画请求 - {{.StatManga}} -
-
- 音频请求 - {{.StatAudio}} -
-
- 视频请求 - {{.StatVideo}} -
-
- 分类请求 - {{.StatCategory}} -
-
- -
-
◆ 缓存统计
-
- 正文缓存数 - {{.CacheCount}} -
-
- 正文缓存 - {{.CacheStatus}} -
-
- 自动清理 - {{.CleanupStatus}} -
-
- 清理间隔 - {{.CleanupInterval}} -
-
-
- -
-
◈ API 接口文档
- - -
-
书籍相关
- -
-
GET/search
-
◆ 搜索书籍
-
-
- query - 必需 - 搜索关键字 + +
+ + + 书籍相关 + 6 + +
+
+
GET/search搜索书籍
+
+
query必需搜索关键字
+
offset可选偏移量,默认0
+
tab_type可选类型,默认3
-
- offset - 可选 - 偏移量,默认0 +
+ +
+
GET/detail获取书籍详情
+
+
book_id必需书籍ID
-
- tab_type - 可选 - 类型,默认3 +
+ +
+
GET/info获取书籍详情(同 detail)
+
+
book_id必需书籍ID
+
+
+ +
+
GET/catalog获取书籍目录
+
+
book_id必需书籍ID
+
+
+ +
+
GET/directory/all_items获取书籍目录(同 catalog)
+
+
book_id必需书籍ID
+
+
+ +
+
GET/multi-detail批量获取书籍详情
+
+
book_id必需书籍ID列表,逗号分隔
- -
-
GET/detail
-
◆ 获取书籍详情
-
-
- book_id - 必需 - 书籍ID +
+ +
+ + + 内容相关 + 3 + +
+
+
GET/content获取章节正文内容
+
+
item_id必需章节ID,支持路径参数或查询参数
+
text_mode可选纯文本模式:1/true=纯文本,0/false=HTML
+
image_mode可选图片处理:1/true=保留图片,0/false=移除图片
+
review可选段评功能:1=启用,0=禁用
+
+
+ +
+
GET/multi-content批量获取多个章节正文
+
+
book_id必需书籍ID
+
item_ids必需章节ID列表,多个ID用逗号分隔,如:123,456,789
+
text_mode可选纯文本模式:1/true=纯文本,0/false=HTML
+
image_mode可选图片处理:1/true=保留图片,0/false=移除图片
+
ignore_failed可选忽略失败章节:1/true=忽略,0/false=不忽略
+
+
+ +
+
GET/item-summary获取章节摘要信息
+
+
book_id必需书籍ID
+
item_ids必需章节ID列表,逗号分隔
- -
-
GET/catalog
-
◆ 获取书籍目录
-
-
- book_id - 必需 - 书籍ID +
+ +
+ + + 段评相关 + 3 + +
+
+
GET/chapter_review获取章节评论列表
+
+
book_id必需书籍ID
+
item_id必需章节ID
+
+
+ +
+
GET/review获取段落评论、回复
+
+
item_id必需章节ID
+
book_id必需书籍ID
+
para必需段落索引(从0开始)
+
comment_id可选评论ID(获取回复时需要)
+
+
+ +
+
GET/review_html获取段落评论HTML页面
+
+
item_id必需章节ID
+
book_id必需书籍ID
+
para必需段落索引(从0开始)
- -
-
GET/multi-detail
-
◆ 批量获取书籍详情
-
-
- book_id - 必需 - 书籍ID列表,逗号分隔 +
+ +
+ + + 多媒体 + 3 + +
+
+
GET/manga获取漫画内容
+
+
item_ids必需章节ID
+
output可选输出格式:url/proxy/data_uri,默认url
+
+
+ +
+
GET/audio获取音频信息
+
+
item_ids必需章节ID
+
tone_id可选音色ID,默认1
+
+
+ +
+
GET/video获取视频信息
+
+
item_ids必需章节ID
-
- - -
-
内容相关
- -
-
GET/content
-
◆ 获取章节正文内容
-
-
- item_id - 必需 - 章节ID,支持路径参数或查询参数 + + +
+ + + 分类 + 2 + +
+
+
GET/category-front获取分类首页
+
+
tab可选标签页,默认0
-
- text_mode - 可选 - 纯文本模式:1/true=纯文本,0/false=HTML -
-
- image_mode - 可选 - 图片处理:1/true=保留图片,0/false=移除图片 -
-
- review - 可选 - 段评功能:1=启用,0=禁用 +
+ +
+
GET/category-landing获取分类详情页
+
+
category_id必需分类ID
+
offset可选偏移量,默认0
+
limit可选每页数量,默认20
- -
-
GET/multi-content
-
◆ 批量获取多个章节正文
-
-
- book_id - 必需 - 书籍ID -
-
- item_ids - 必需 - 章节ID列表,多个ID用逗号分隔,如:123,456,789 -
-
- text_mode - 可选 - 纯文本模式:1/true=纯文本,0/false=HTML -
-
- image_mode - 可选 - 图片处理:1/true=保留图片,0/false=移除图片 -
-
- ignore_failed - 可选 - 忽略失败章节:1/true=忽略,0/false=不忽略 -
+
+ +
+ + + 系统 + 1 + +
+
+
GET/status系统状态页面(当前页面)
- -
-
GET/item-summary
-
◆ 获取章节摘要信息
-
-
- book_id - 必需 - 书籍ID -
-
- item_ids - 必需 - 章节ID列表,逗号分隔 -
-
-
-
- - -
-
段评相关
- -
-
GET/review
-
◆ 获取段落评论、回复
-
-
- item_id - 必需 - 章节ID -
-
- book_id - 必需 - 书籍ID -
-
- para - 必需 - 段落索引(从0开始) -
-
- comment_id - 可选 - 评论ID(获取回复时需要) -
-
-
- -
-
GET/review_html
-
◆ 获取段落评论HTML页面
-
-
- item_id - 必需 - 章节ID -
-
- book_id - 必需 - 书籍ID -
-
- para - 必需 - 段落索引(从0开始) -
-
-
-
- - -
-
多媒体
- -
-
GET/manga
-
◆ 获取漫画内容
-
-
- item_ids - 必需 - 章节ID -
-
- output - 可选 - 输出格式:url/proxy/data_uri,默认url -
-
-
- -
-
GET/audio
-
◆ 获取音频信息
-
-
- item_ids - 必需 - 章节ID -
-
- tone_id - 可选 - 音色ID,默认1 -
-
-
- -
-
GET/video
-
◆ 获取视频信息
-
-
- item_ids - 必需 - 章节ID -
-
-
-
- - -
-
分类
- -
-
GET/category-front
-
◆ 获取分类首页
-
-
- tab - 可选 - 标签页,默认0 -
-
-
- -
-
GET/category-landing
-
◆ 获取分类详情页
-
-
- category_id - 必需 - 分类ID -
-
- offset - 可选 - 偏移量,默认0 -
-
- limit - 可选 - 每页数量,默认20 -
-
-
-
- - -
-
系统
- -
-
GET/status
-
◆ 系统状态页面(当前页面)
-
- -
-
GET/self-info
-
◆ 设备信息(包含敏感数据)
-
-
-
+ + + +
diff --git a/openapi.yaml b/openapi.yaml index 0cfc445..798eec0 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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: